본문 바로가기
  • AI (Artificial Intelligence)
Skills/Unix, Linux

kernel system call - do_brk()

by 로샤스 2014. 4. 16.

DPDK 문서를 보다가 커널이 프로세스에게 메모리를 동적으로 할당하는 데 쓰이는
do_brk() 라는 System call 이 kernel 2.4 이하에서 취약점이 발견되어
이것을 이용하면 시스템의 root 를 획득할 수 있다고 하는 흥미로운 기사를 발견했다.
 
아래는 그 내용이다.
 
::: 버전 2.4.22 이하 리눅스 커널의 do_brk() 취약점 :::

작성일 : 2003년 12월 6일
작성자 : iSEC Security Research (http://isec.pl)
번역 : 해커스쿨 (http://hackerschool.org)

이 문서는 지난 12월 초에 발견된 새로운 리눅스 커널 결함에 대하여 외국의
iSEC Security Research사에서 분석한 문서를 번역한 것입니다.
번역과 배포의 목적은 이 취약점 분석을 통한 리눅스 커널에 대한 지식
확충을 위해서이며, 공격과 관련된 악성 코드는 악용 가능성이 있음으로
번역/첨부하지 않습니다.

[문서 목차]
1. 개요
2. 리눅스의 메모리 관리
3. 취약점 요약
4. 상세 분석
1) 공격 구상
2) 힙 영역의 확장
3) 커널 메모리 보호의 우회
4) 커널 구조체
5) 권한 상승
6) Cleanup 문제

1. 개요

버전 2.4.22 이하의 리눅스 커널에서 메모리 관리 체계에 관련된 심각한 보안
취약점이 발견되었다. 그리고 이 취약점은 오픈소스 커뮤니티조차도 알려지지
않은 채 조용히 2.4.23 버전과 2.6.0-test6 releaqse 버전에 패치되어 배포되었다.
커널 개발자들은 이 취약점이 악용 가능하다고 생각하지 않았던건지, 아니면
그들이 만든 소프트웨어에 대한 새로운 보안 권고문을 쓴다는 것이 두려웠던
건지는 모르는 일이다.

2003년 9월말, 리눅스 커널에 대한 일반적인 분석을 하던 도중, 우리는 똑같은
버그를 발견했고, 그것의 심각한 영향을 즉각 이해했다. 또한, 멀지않아 우리는
간단한 익스플로잇 코드도 구현할 수 있었다.

이 문서에서는 do_brk() 함수에서 발견된 취약점의 기술적인 상세 내용과
익스플로잇을 구현하는 동안의 우리의 연구 결과를 기술하고 있다.
이 문서는 또한, 우리가 효과적인 익스플로잇을 만들기 위해 사용했던 수 없이
많은 기술적 노하우(각종 보안 패치로 방어된 커널에서의 권한 상승 등)를
담고 있다.

2. 리눅스의 메모리 관리

x86 계열의 CPU에서 구동되는 최근의 리눅스 커널은 간소화된 가상 메모리 모델에
의하여 관리된다. 32비트 아키텍쳐에서는 각 사용자 프로세스가 0에서 4기가바이트
까지의 가상 메모리를 사용할 수 있다. 이것은 실제 물리 메모리보다 훨씬 많은
양이다. 가상 메모리는 선형의 주소 공간이 4kb 사이즈의 페이지 단위로 나뉘어진
것과 같다. 이 페이지들은 물리 메모리 페이지에 적당하게 매핑된다. 프로세스의
페이지 테이블은 매핑된 각각의 페이지를 위한 보호 특성을 포함한 추가적인 특성
을 가지고 있다.

프로세스의 가상 메모리는 두부분으로 나뉜다. TASK_SIZE 는 커널 상수로서, 가장
낮은 권한 레벨에서 돌아가는 코드에 대한 접근 가능한 메모리의 최대 제한 값을
정의한다.

1기가바이트 이하의 물리 메모리를 사용하는 시스템에서는 일반적으로 이 값이
0xc0000000으로 정의되어 있다. (이 문서의 모든 예제는 이 값을 참조한다.)
이 제한선 위에 있는 메모리에는 데이터 구조를 포함한 커널 코드가 있지만,
페이지 프로텍션 메카니즘에의해 유져에게는 접근 권한을 허락하지 않는다.
이 부분은 단지 커널로의 접근 권한이 있는 코드에 의해서만 제어가 가능하다.

TASK_SIZE 제한선 아래쪽의 일반 유져가 접근할 수 있는 메모리 영역은 더욱 많은
논리 구역으로 나뉘어져 있다. 각 영역은 가상 주소 범위와 프로텍션 특성에 의해
달라진다. 또한, 각 영역은 서로 다른 기능을 수행한다. 예를들어, ".text"라는
영역은 적재된 바이너리의 실행 가능한 코드를 담고 있고, ".data" 영역은 읽기/
쓰기가 가능한 데이터를 담고 있으며, ".rodata" 영역은 읽기만 가능한 데이터를
담고 있다.

일반적인 사용자 프로세스의 메모리 구조는 다음과 같다:

bash$ cat /proc/self/maps
08048000-0804c000 r-xp 00000000 03:02 207935 /bin/cat
0804c000-0804d000 rw-p 00003000 03:02 207935 /bin/cat
0804d000-0804e000 rwxp 00000000 00:00 0
40000000-40015000 r-xp 00000000 03:02 213752 /lib/ld-2.3.2.so
40015000-40016000 rw-p 00014000 03:02 213752 /lib/ld-2.3.2.so
40016000-40017000 rw-p 00000000 00:00 0 40020000-40021000 rw-p 00000000 00:00 0
42000000-4212f000 r-xp 00000000 03:02 319985 /lib/tls/libc-2.3.2.so
4212f000-42132000 rw-p 0012f000 03:02 319985 /lib/tls/libc-2.3.2.so
42132000-42134000 rw-p 00000000 00:00 0
bfffc000-c0000000 rwxp ffffd000 00:00 0

이 리눅스 커널에서의 메모리 섹션들은 가상 메모리 영역이라고도 불리어진다.

효율적인 메모리 사용을 위하여 커널은 각 프로세스의 모든 가상 메모리 영역을
직접 관리한다. (스와핑, 디맨드 로딩, 프로젝션 폴트 핸들링 등..)
각각의 가상 메모리 영역은 에 정의된 vm_area_struct 구조체에
설명되어 있으며, 이 구조체에서 가장 중요한 부분은 다음과 같다.

struct vm_area_struct {
unsigned long vm_start;
unsigned long vm_end;
pgprot_t vm_page_prot;
/* ... */
}

프로세스의 가상 메모리 영역은 메모리 디스크립터 구조체(mm_struct)와 연결
되어 있다. 이것은 mm 멤버 변수와 다음의 구조체를 이용해서 프로세스의
디스크립터(task_struct)를 참조한다.

struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
/* ... */
int map_count; /* number of VMAs */
/* ... */
unsigned long start_brk, brk, start_stack;
/* ... */
}

리눅스 메모리 관리에 관련된 더 자세한 설명은 다음 섹션에서 계속된다.

3. 취약점 요약

do_brk()는 프로세스의 메모리 힙(brk)을 관리하여 그것을 알맞게 늘이고 줄이기
위해 간접적으로 호출되는 커널 내부 함수이다. 사용자는 내부적으로 do_brk()를
호출하는 brk(2) 시스템 콜을 이용하여 힙을 조작할 수 있다. do_brk()는 nmap(2)
시스템 콜의 간소화된 버전으로, 초기화되지 않은 데이터를 위한 익명의 매핑(
anonymous mappings)만을 다룬다.

do_brk() 함수는 인자 값에 대한 바운드 체킹을 하지 않기 때문에 이를 이용하여
임의의 큰 가상 메모리 영역을 생성하도록 함으로써 유져가 접근 가능한 메모리
영역을 초과할 수 있게 된다.

일반적인 환경에서 힙은 프로세스의 가상 메모리에 포함된 한 구역이며, TASK_SIZE
경계 아래로 수 킬로바이트에서 수 메가바이트의 범위에 분포된다. 일반적으로
힙은 주로 malloc() 라이브러리 함수에 의해 동적 할당된 데이터를 취급하는데
이용된다. do_brk() 커널 함수가 바운드 체킹을 하지 않음으로 TASK_SIZE 경계
위쪽까지 힙 영역이 확장되는 것이 가능하다. 따라서, 커널 메모리 관리 시스템은
사용자가 접근할 수 없도록 보호된 커널 영역의 메모리가 유져 프로세스의 힙에
속하는 것으로 착각하게 된다. 그러나, 이 속임수는 커널 메모리의 직접적인
접근을 가능하게 하지는 않는다. 커널 페이지는 CPU의 MMU 장치에 의해서 보호
되기 때문이다. 하지만, 커널 페이지의 보호를 교란시키기 위해 아주 큰 VMA를
조작하는 다른 시스템 콜을 사용함으로써 공격이 가능해진다.

4. 상세 분석

1) 공격 구상

불완전한 do_brk() 함수는 usblib() 바이너리 포맷 핸들러로부터 뿐만아니라
ELF와 a.out 바이너리 포맷 로더에 의해서도 내부적으로 호출된다.
do_brk() 취약점을 익스플로잇 하기 위해 sys_brk() 콜과 더불어 3가지 다른
방법이 사용될 수 있다. 이 문서에서는 공격을 위하여 sys_brk() 시스템 콜만을
사용할 것이다.

2) 힙 영역의 확장

힙은 요청된 주소 범위가 이미 할당되지 않았을 경우에만 확장이 허가 된다.
일반적인 프로세스의 스택 영역은 보통 TASK_SIZE 주소 바로 아래인 프로세스
메모리의 가장 상위 부분에 위치한다. 따라서, 스택은 익스플로잇하기 전에
반드시 메모리의 어디론가로 옮겨져야 한다. 다음 단계는 힙이 프로세스의
메모리 배치에서 가장 마지막 부분이 되도록 하는 것이다.

이제 우리는 힙을 커널 메모리 영역에 걸치도록 하기 위해서 brk(2) 시스템 콜을
이용할 것이다. 이것은 반드시 매번 상대적으로 적은 바이트의 힙을 늘려가며
여러번에 걸쳐 brk를 호출하여 행해져야 한다. 이것은 가상 메모리에 매핑되어
있을 커널 경계를 do_brk() 함수를 이용하여 한번에 우회할 필요가 있기 때문
이다.

이 세가지 단계 이후에 힙의 모습은 아마도 다음과 같을 것이다.

080a5000-fffff000 rwxp 00000000 00:00 0

만약 가상 메모리의 모든 영역이 종료된 프로세스 또는 매핑되지 않은 메모리
페이지에 속하거나 커널 메모리 관리자에게로 넘어가 버린다면, 우리의 프로세스는
종료될 것이다. 따라서, 시스템을 불안정하게 하거나 리부팅을 야기하는 커널
메모리의 일부분은 접근을 하지 않는다.

3) 커널 메모리 보호의 우회

힙 영역을 0xc0000000 경계 이상으로 확장한 후에도 아직 유져 프로세스에 의한
직접적인 접근은 불가능하다. 모든 커널 메모리 페이지는 슈퍼바이저 비트란 것이
붙어 있기 때문이다. 이러한 승인되지 않은 접근은 하드웨어 MMU 장치에 의해서도
방지된다. 하지만 ptrace(2) 시스템 콜을 이용한 간접적인 접근은 가능할 수도
있다. 그러나, 이 방법은 보통 대부분의 리눅스 시스템에서 방지되어 있음으로
사용하지 않을 것이다.

따라서 우리는 커널 페이지에 접근하기 전에 프로텍트를 해제할 것이다. 즉,
커널 페이지를 읽고, 쓰기 가능한 상태로 만든다. 다행이도 간단한 연구 끝에
올바른 가상 메모리 영역이 프로세스의 메모리 기술자에 존재한다면, mprotect(2)
시스템 콜이 커널 페이지에도 완벽히 먹혀든다는 것을 발견했다. 따라서, 우리는
커널 내부의 거의 어떠한 페이지 프로텍트도 원하는대로 변경할 수 있다.

하지만, 페이지 사이즈 확장(PSE) 기능을 가지고 있는 어떤 x86 프로세서는
성능 상의 이유로 커널 코드 페이지 크기를 4MB로 되게 한다. 그리고 mprotect(2)
시스템 콜은 즉각적인 충돌을 일으킬 그런 큰 페이지는 다루지 않는다. 그것은
오직 4kb 크기의 페이지에만 사용된다. 그러한 큰 크기의 페이지들은 커널
메모리 kmalloc()와 vmalloc() 할당자에 의해 사용된다. vmalloc() 함수는 한
예로 커널 모듈을 위한 메모리 할당에 사용된다.

지금까지의 정보를 이용하여 우리는 kmalloc, vmalloc화된 커널 메모리에 어떠한
것도 쓸 수 있음을 알았다. 두가지 중요한 문제는 무엇을 쓰고 또, 그것을
어디에다가 쓸 것인가하는 것이다.

4) 커널 구조체

우리는 커널 메모리 allocator를 이용하여 약간의 데이터를 커널 메모리 상에
잠시동안 상주하게 만들 수 있다. 그리고 우리가 그 내용물을 수정한 후에 쉽게
권한을 상승시킬 수 있는 구조체를 찾아야한다.

프로세스의 LDT(local descriptor table)은 segment descriptor라고 하는 각각의
세그먼트 경계와 접근 권한을 기술하는 배열을 가지고 있다. 이 배열은 modify_
ldt(2) 시스템 콜을 이용해서 LDT 엔트리에 기록되는 프로세스를 vmalloc()를
사용하여 할당한다. LDT는 프로세스가 끝나지 않고 있을 때까지 메모리에 존재
한다. 커널은 LDT 배열의 엔트리를 쓰기 위한 권한을 제한한다. 이것은 ring0
권한이라고 불리는 프로세스로부터 사용자 프로세스의 LDT 오용을 막기 위해서
이다. 그러므로, 만약에 우리가 LDT 배열의 어떤 엔트리에 값을 쓸 수만 있다면
우리는 쉽게 권한을 상승시킬 수 있을 것이다.

커널 메모리의 layout은 시스템마다 다르다. 그것은 커널 설정과 컴파일러와
컴파일 옵션에 의존적이다. 메모리 할당에 의해서 리턴되는 주소 값은 매우 예측
하기 어렵다. 이 부분이 익스플로잇의 가장 어려운 부분이다.

우리의 목표는 추측하여 공격하지 않는 것이다. 우리는 커널 메모리의 LDT 배열의
정확한 주소를 찾는 방법을 원한다. 그리고 이 부분은 익스플로잇을 만드는데
가장 많은 시간이 소모되었다. 그것은 바로 리눅스의 signal handling를 사용하는
것이다.

만약 시그널이 임의로 설치된 시그널 핸들러와 함께 프로세스로 보내진다면, 그
시그널 핸들러 루틴은 시그널의 정보(시그널 전송자와 그 시그널이 보내진 이유
등..)를 받게될 것이다. SIGSEGV 시그널은 유져 프로세스가 접근 불가능한 메모리
영역에 읽거나 쓰기를 시도할 때마다 보내진다.

그리고 각각의 페이지 폴트는 do_page_fault() 커널 함수에 의해서 다루어진다.
이 함수의 인자들 중 하나는 CPU에서 제공되는 에러코드이다. 이 인자는 page
fault가 필요한 정확한 이유와 faulty page를 적재하는 등, page fault에 대한
처리가 필요한지를 기술하며, copy-on-write 혹은 잘못된 메모리의 요청을 한
경우에 SIGSEGV 시그널을 이용해서 죽이기 위해서 사용된다.

SIGSEGV 시그널이 발생하는 경우, 커널의 do_page_fault() 루틴은 에러 코드
값을 시그널 핸들러에게 고의적으로 누락한다. 우리가 관심을 가지는 것은
다음 두 경우에 대한 에러코드 값이다.

- 메모리에 페이지가 맵핑 되지 않아서 page fault 가 일어나는 경우
- 페이지 프로텍트가 접근을 허락하지 않아서 page fault 가 일어나는 경우

그러므로 에러코드의 값은 페이지가 사용자에 의해서 직접적으로 접근이 불가능
함에도 불구하고 TASK_SIZE 제한 위의 주소가 커널 주소 공간에 페이지 맵
되었는지 아닌지를 판단하기에 적당하다. 이 조건은 예를들어 커널 메모리의
정확한 맵을 생성하는 verr 어셈블러 명령어를 사용하는 TASK_SIZE 경계 위쪽의
각 페이지를 체크한다.

만약에 우리가 커널에 할당하기 전과 LDT 메모리를 할당한 뒤의 배열의 2개의
맵을 만들수 있다면, 우리는 쉽게 이 맵을 비교할수 있고, 커널 구조에 할당된
정확한 주소 값을 알 수 있을것이다.

5) 권한 상승

커널 메모리 안의 LDT 배열을 찾은 후에 우리는 그곳에 gate 디스크립터 함수를
생성할 수 있다. 이 gate 디스크립터 함수는 유져 레벨에서 커널 레벨로 권한을
상승하는 역할을 한다.

i386에서의 gate 함수는 세그먼트 셀렉터 코드와 gate 코드로 향하는 엔트리
포인트를 포함하고 있다. 세그먼트 셀렉터 코드는 gate 함수에 의하여 실행되는
코드의 권한을 결정한다. 반면에, 디스크립터 권한은 호출된 코드의 필요 권한을
결정한다.

gate 함수는 일반 프로세스를 커널 모드로 변경하는 int $0x80 시스템콜 메카니즘
과 비슷한 방식으로 작동한다. 시스템 콜 신호와의 가장 큰 차이점은 쓰기 가능한
LDT에 단지 CPLO 권한에서 호출된 실행 가능한 루틴의 주소만을 저장할 수
있다는 것이다.

우리는 디스크립터 권한 레벨 3과 KERNEL_CS(CPLO를 위한 커널 코드 디스크립터)와
같은 값을 가진 세그먼트 코드를 가진 LDT 안의 gate 함수를 TASK_SIZE 보다 낮은
값의 프로세스 주소 공간으로 가리키기로 결정했다. 그럼으로 유져 모드의 태스크
가 CPLO에서 자신의 코드를 직접 실행 할 수 있게 된다.

이 태스크를 실행하기 위해서는 현재 프로세스로의 포인터와 실제 exploit 코드를
담은 C 함수를 가르키도록 계산된 어셈블러 코드가 CPLO의 레벨이 어떤 커널
구조체로도 변경할 수 있는 상태로 실행되는 동안에 가능하다.

프로세스의 권한을 변경하는 것은 아주 쉬운 작업이다.
우리가 해야할 오직 한가지 작업은 커널 메모리 안 어딘가에 존재하는 task_struct
를 찾은 후 그것의 UID, GID와 capability set을 변경하는 것이다.
task_struct에 대해 자세히 알고 싶다면, 커널 소스의 를 보면된다.

하지만, 만약 EUID=0을 얻은 후에 다른 바이너리를 직접 실행하려는 단순한
원리라면, 프로세스의 EUID와 EGID가 모두 바뀌어야만 한다. 왜냐하면, EUID=0에
의해 호출된 execve() 시스템 콜은 전체 프록세스의 능력이 reenable하게 되기
때문이다.

6) Cleanup 문제

권한을 상승시킨 후에는 시스템이 다운 되는 것과 해당 프로세스가 깨끗하게
종료되지 않는 것을 방지하기 위하여 CPLO 안에 clean up 코드 실행을 위한
공간을 만들어야 한다. 우리의 아이디어는 TASK_SIZE의 제한을 확장한
vm_area_struct 구조체를 커널 메모리에서 찾는 것이다. 이 구조체들이
TASK_SIZE 값까지 유지되도록 변경하면 안정적인 상태로 프로그램을 종료하기에
충분하다.

 

 

 

 

 

 

출처 : http://sthyun.tistory.com/entry/kernel-system-call-dobrk

 

 

 

 

 

 

 

'Skills > Unix, Linux' 카테고리의 다른 글

권한(Permission) 과 chmod & chown  (0) 2014.05.22
UNIX Kernel  (0) 2014.04.22
What makes an operating system “Unix-Like”?  (0) 2014.04.16
NCURSES Programming HOWTO  (1) 2014.04.16
pscp로 파일 전송하기  (1) 2014.04.15

댓글