티스토리 뷰
원본 : https://www.gatewatcher.com/en/news/blog/windows-kernel-pool-spraying
- 여기에서 설명한 내용은 Windows 8 이후부터 단순 pool spray 방법만 사용 가능하다.
- Introduction의 논문 link [1][2]와 결론의 [5]는 원본 link에 있음
--------------------------------------------------------------------------------------------------
1) Introduction
kernel의 pool 할당자는 여러 chunk들에 대해서 각각의 chunk 헤더를 하나하나 체크하기 때문에 kernel의 pool에서 발생하는 취약점을 익스플로잇할 때 BSOD를 보지 않기 위해서는 chunk와 pool의 메타데이터를 제어해주어야 한다. pool spray는 예측 가능한 pool을 할당하는 기법인데, chunk가 할당될 위치와 chunk 주변을 알아내는 것이 가능하다. 만약 특정 정보를 leak하거나 특정 데이터를 덮어쓰려고 하는 경우에 필요한 기술이다. 이 글의 목적은 pool spray에 대해 설명하는 것이기 때문에 pool의 내부를 깊이 설명하는 것이 아니고, pool spray를 위해 알아야 할 기본 지식에 대해서만 설명한다. 따라서 만약 pool 내부의 자세한 정보가 필요한 경우, Tarjei Mandt의 논문 [1][2]를 읽어야 한다. 또한 x64 아키텍쳐에 대해서만 설명한다.
2) 커널 내부의 일부분
pool은 Windows kernel 내부의 모든 할당을 담당하는 매우 기본적인 요소이기 때문에, 매우 많이 사용되서 기본적인 heap보다 제어하기 더 까다롭다. pool은 기본적인 문자열부터 매우 큰 구조체까지 모든 타입의 데이터를 관리하는데, pool도 할당자와 자체적인 구조를 가지고 있는데, 이는 heap과 크게 다르지 않다. Windows 운영 체제 커널은 물리적 메모리에 계속 유지되는 Non-paged pool과 물리적 메모리 밖으로 페이징될 수 있는 paged pool을 따로 세팅해서 사용한다. Non-paged pool은 Windows 8 이후부터는 NonPagedPoolNx가 도입되어 기본적으로 DEP가 걸린 Non-paged pool이 생성된다(반대로 8 이전은 DEP가 걸리지 않아 실행 가능함). pool에는 여러 종류가 있지만, 전반적인 구조는 동일한데 Pool Descriptor는 다음과 같은 pool의 현재 상태 정보를 해당 구조에 맞춰 저장한다.
- Deferred free(Delayed Free) list (기본적으로 활성화됨) : list가 채워졌을 때 해제될 chunk list
- ListHeads : 크기별로 정렬된 free된 chunk들의 list(LIFO 자료구조)
- Lookaside list : 크기별로 정렬된 free된 chunk들의 list(LIFO 자료구조). ListHeads list와 매우 비슷해 보이지만, 약간의 제한 사항을 가짐
- 작은 크기의 free된 chunk들의 list (성능 향상을 위한...)
- chunk의 크기 : 0x200 Bytes보다 작거나 같음.
- 현재 할당에 대한 기타 정보들
요약하면, pool은 할당된 page들의 list다. 페이지는 0x1000 bytes의 크기를 가지는데, chunk라는 조각으로 나눠진다. chunk에는 0x1000 bytes보다 큰 chunk도 있는데, 이는 특별한 경우이고 여기서는 생략한다. 따라서 0xff1 bytes보다 작은 크기의 chunk에 초점을 맞출 것이다. 다음은 kernel pool chunk의 구조이다:
- PreviousSize : 이전 chunk의 block 크기. 이 때 block의 크기는 실제 크기 >> 4(16으로 나눈 크기)로 저장된다.
- PoolIndex : 해당 pool의 type의 pool descriptor 배열에서 현재 chunk의 pool descriptor를 가져오기 위한 index
- BlockSize : chunk의 block 크기. 이 때 block의 크기는 실제 크기 >> 4(16으로 나눈 크기)로 저장된다.
- Pool Type : 해당 chunk의 정보(bitmask 형태)
- pool type : Non-paged Pool or Paged Pool
- 할당 상태 여부
- Quota bit : 해당 chunk가 프로세스 자원 관리에 사용되는지의 여부(해당 bit가 설정된 경우, EPROCESS 객체의 주소가 ProcessBilled에 저장됨)
- PoolTag : 디버깅에 사용되는 chunk 식별자
- ProcessBilled : Quota bit가 설정된 경우, EPROCESS 객체의 주소
Kernel Pool의 할당/해제
Pool은 chunk를 할당하기 위해 3가지 방법을 가지고 있다:
- chunk의 크기가 0x200 bytes 이하인 경우, 할당자는 먼저 lookaside list를 확인하여 요청한 크기와 같은 크기의 chunk가 있는지 확인한다. 있는 경우 해당 chunk를 반환한다.
- ListHeads list를 확인하여 요청한 크기와 같은 크기의 chunk가 있는지 확인한다. 있는 경우 이를 그대로 반환하고, 없는 경우 더 큰 크기의 chunk를 2개로 나누어 1개는 반환하고, 남은 1개는 ListHeads list에 정렬되어 저장된다.
- ListHeads에도 적절한 chunk가 없는 경우, 새로 페이지를 할당하여 chunk를 반환한다. 기본적으로 페이지의 최하위부터 chunk를 할당하지만, 페이지에 처음 할당되는 chunk는 페이지의 최상단에 할당된다.
chunk를 해제할 때도 할당과 비슷한 메커니즘으로 각각 저장된다:
- chunk의 크기가 0x200 bytes보다 작은 경우, 해당 chunk type의 lookaside list에 크기별로 정렬되어 저장한다.
- Delayed free가 설정된 경우, Delayed free list에 저장한다. 해당 list가 꽉 찬 경우, 한번에 list 내부의 모든 chunk를 해제
- chunk가 해제되고 별도로 저장이 되지 않은 경우, 현재 페이지에 사용중인 chunk가 있는지 확인한다. 현재 페이지에 사용 중인 chunk가 있는 경우 ListHead에 저장하고, 사용중인 chunk가 없는 경우 페이지 전체를 해제하고 정리한다.
3) Pool Spray 기본
Pool Spray의 기본은 나중에 할당되었을 때 제어할 수 있도록 충분한 크기의 객체를 할당하는 것이다. Windows는 다양한 Type의 pool에 객체를 할당할 수 있게 많은 도구를 제공한다. 예를 들자면, NonPagedPool에 ReservedObjects나 Semaphores를 할당 가능하다. 제어하고자 하는 pool의 type과 일치하는 type의 객체를 찾는 작업은 별도로 찾아야 하는데, 할당하고자 하는 객체의 크기는 제어하고자 하는 객체의 크기와 직접적으로 연관되어 있기 때문에 매우 중요하다. 일단 할당할 객체를 정하였다면 해당 객체를 최대한 많이 할당하여 pool이 랜덤으로 할당되는 위치를 최소화시킨다. 이 때, 다음과 같은 pool 페이지가 만들어져야 한다:
User-land에서 CreateFile 등을 호출하면 Kernel Pool에 Kernel Object가 할당되는데, Kernel은 Kernel-land의 주소를 그대로 반환주는 것이 아니라 Kernel Object에 대한 핸들을 반환해준다. 이러한 핸들은 CloseHandle()을 호출함으로써 Kernel Object를 해제가능하다. 이러한 방법으로 Kernel Object를 대량으로 할당하면 Lookaside와 ListHead list의 사용 가능한 chunk를 전부 소모 가능하고, 이 후 새로운 페이지를 할당하여 객체를 할당하도록 할 수 있다. 이렇게 할당된 객체와 핸들의 목록을 확인해보면 pool과 핸들간의 상호연관 관계를 확인 가능하다.
이러한 관계를 확인한 후 CloseHandle()을 호출하여 특정 chunk 및 인접한 chunk를 해제함으로써, 반제어 가능한 크기의 공간을 쉽게 만들 수 있다.
다음과 같은 경우, 문제가 발생할 수도 있다.
- 제어하고자 하는 객체의 크기가 0x200 bytes보다 크고, gap을 만들기 위해 해제한 객체가 0x200보다 작은 경우 : lookaside list에 gap을 만들기 위해 해제한 객체가 저장되어 해제된 chunk가 병합되지 않는다. 따라서 gap을 구성하기 전에 객체를 lookaside list를 꽉 채울 만큼 충분하게 해제해줘야 한다.
- gap을 만들기 위해 해제한 chunk가 DeferredFree list에 저장된 경우 : DeferredFree list를 다 채우지 않으면 해제되지 않기 때문에 병합 또한 되지 않는다. 따라서 gap을 만들 공간의 객체를 해제하고, Deferred list 충분하게 채울 만큼 다른 공간의 객체를 해제해줘야 한다.
- 기타 : Pool은 커널 전체에서 할당하고 사용되기 때문에, 제어하기 위해 생성한 gap 공간에 커널에서 사용하기 위한 Pool이 할당될 수 있다. 따라서 제어하기 위해 생성한 gap 공간을 빠르게 제어하고자 하는 객체로 채워야 한다.
단계에 대한 요약:
- 핸들을 사용하여 해제할 chunk를 선택
- lookaside list를 채울 만큼 chunk를 충분하게 해제
- 1번에서 선택한 chunk를 해제(gap 공간 생성)
- DeferredFree list를 채울 만큼 chunk를 충분하게 해제
- 최대한 빠르게 3번에서 생성한 gap 공간 사용
4) leak을 사용한 연계
앞에서 Windows는 객체의 주소(kernel-land)를 알려주지 않는다고 하였다. 이는 거짓말이다.
Windows에서는 NtQuerySystemInformation()이라는 함수를 사용한 유명한 leak 방법이 있다. 이 함수는 커널의 주소를 leak할 수 있게 허용하는데, 다음과 같은 구조를 제공하면 현재 할당된 모든 객체의 list를 제공해준다:
typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX { PVOID Object; ULONG_PTR UniqueProcessId; HANDLE HandleValue; ULONG GrantedAccess; USHORT CreatorBackTraceIndex; USHORT ObjectTypeIndex; ULONG HandleAttributes; ULONG Reserved; } SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO_EX; |
typedef struct _SYSTEM_EXTENDED_HANDLE_INFORMATION { ULONG_PTR NumberOfHandles; ULONG_PTR Reserved; SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; } SYSTEM_EXTENDED_HANDLE_INFORMATION, *PSYSTEM_EXTENDED_HANDLE_INFORMATION; |
_SYSTEM_EXTENDED_HANDLE_INFORMATION 구조체를 NtQuerySystemInformation() 함수의 SystemExtendedHandleInformation 인자로써 전달해 호출한다. 이 때, Handles 필드를 사용하여 System에 할당된 모든 객체의 목록을 얻을 수 있다. 모든 객체는 _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 구조체를 통해 설명되는데, 다음과 같다:
- HandleValue 필드 : 객체를 할당했을 때 얻은 핸들 값과 일치
- Object 필드 : kernel pool 메모리에 있는 객체의 주소
- 핸들을 사용하여 객체의 커널 주소를 얻을 수 있었다.
※ 주의 : Windows 8 이후부터는 low intergrity level에서 사용할 수 없기 때문에 leak을 하는데 사용할 수 없다. 만약 low intergrity level이라면, 기본적인 spray만 할 수 있다.
spray를 100% reliable하게 만들기
2개의 객체를 각각 할당했을 때, pool 메모리에 서로 인접하지 않는 경우도 많기 때문에 지금까지의 pool에 gap을 만드는 방법으로는 신뢰하기 어렵다. 전혀 다른 페이지에 할당되었거나, 알 수 없는 위치의 chunk가 할당되었을 가능성도 높다.
그래서 이런 경우는 address leak을 사용하여 생성하고자 하는 gap이 유효한지 쉽게 검증 가능하다:
- 핸들 목록으로부터 핸들을 선택하고, 해당 핸들과 연관된 커널 주소를 leak
- 선택했던 핸들 바로 다음 핸들을 선택하여, 해당 핸들과 연관된 커널 주소를 leak. 이 때의 leak한 주소는 처음 leak 했던 주소에 chunk의 크기를 더한 값과 같아야 한다. 다른 경우 gap을 만들기에 적합하지 않다.
- gap을 만들기 위한 주변 chunk에 대해 동일하게 검증
해당 방법을 통해 gap이 유효하다는 것을 확신 가능하다.
5) 결론
pool spray는 커널의 pool에서 취약점을 exploit하기에 매우 강력하다. 그러나 pool spray를 하기에는 일부 제한 사항이 있다. 첫 번째로, spray를 하기 위해서는 객체의 크기에 항상 의존하기 때문에 임의의 크기를 가진 gap을 생성할 수 없다. 물론 다양한 크기를 가진 gap을 만들기 위해 여러 객체를 혼합하여 spray한다면 가능하다. address leak을 사용할 수 있다면, 생성한 gap의 유효성을 검증하는 것 또한 가능하다. 두 번째로, 0x200 Bytes 이하 크기의 chunk는 할당자가 lookaside list를 먼저 확인하기 때문에 할당을 예측하기 매우 어렵다. 이를 해결하려면 제어하고자 하는 chunk와 정확히 동일한 크기의 객체를 사용해야 한다. [5]를 확인하면 이 글을 작성하는데 설명한 방법의 pool을 spray하기 쉬운 API를 제공하는 라이브러리가 있다.
참고
'System > Windows' 카테고리의 다른 글
[RPC] Remote Procedure Call (0) | 2019.10.18 |
---|---|
[shellcode] EternalBlue에서 사용된 kernel shellcode 분석 (0) | 2019.07.02 |
[Update] Extract Windows update file(.msu) (0) | 2019.06.04 |
[Objects] Job 객체 (0) | 2019.03.27 |
Windows 인증 과정3 (원격) (0) | 2018.04.12 |