윈도우즈 커널 공격과 방어 - Privilege Escalation

윈도우즈 커널 공격과 방어 - RW Primitive에서는 윈도우즈 커널을 공격하기 위한 첫번째 단계인 RW Primitives 확보에 대해서 알아 보았습니다. 이번에는 커널에 대한 RW 권한을 가지고 있을 때에 어떠한 방법으로 프로세스의 권한을 상승시킬수 있는지에 대한 설명을 하려고 합니다. 윈도우즈 시스템에서 권한 상승은 보통 사용자 권한에서 Administrator 권한이나 SYSTEM 권한을 얻어 내는 것입니다. 여기에서 소개할 방법은 커널에서 RW Primitives를 얻어 내어, 커널 메모리를 읽고 쓸수 있다라는 가정하에서 가능한 작업들입니다.

Token Swapping

윈도우즈 커널에서는 프로세스의 리스트를 링크드 리스트 구조체인 _LIST_ENTRY 구조체를 통해서 관리합니다.

typedef struct _LIST_ENTRY {
  struct _LIST_ENTRY *Flink;
  struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, PRLIST_ENTRY;

즉, 다음과 같이 _EPROCESS라는 구조체 안의 ActiveProcessLinks의 Flink, Blink 포인터들을 따라 가다 보면 시스템 내의 모든 프로세스 구조체들을 확보할 수 있습니다.

kd> dt -r _EPROCESS ffff9f8c`a1c54330-2f0
nt!_EPROCESS
   +0x000 Pcb              : _KPROCESS
      +0x000 Header           : _DISPATCHER_HEADER
         +0x000 Lock             : 0n11927555

...
   +0x2f0 ActiveProcessLinks : _LIST_ENTRY [ 0xffff9f8c`a30aeaf0 - 0xfffff800`36b88490 ]
      +0x000 Flink            : 0xffff9f8c`a30aeaf0 _LIST_ENTRY [ 0xffff9f8c`a4170970 - 0xffff9f8c`a1c54330 ]
         +0x000 Flink            : 0xffff9f8c`a4170970 _LIST_ENTRY [ 0xffff9f8c`a4299370 - 0xffff9f8c`a30aeaf0 ]
         +0x008 Blink            : 0xffff9f8c`a1c54330 _LIST_ENTRY [ 0xffff9f8c`a30aeaf0 - 0xfffff800`36b88490 ]
      +0x008 Blink            : 0xfffff800`36b88490 _LIST_ENTRY [ 0xffff9f8c`a1c54330 - 0xffff9f8c`a5d9f370 ]
         +0x000 Flink            : 0xffff9f8c`a1c54330 _LIST_ENTRY [ 0xffff9f8c`a30aeaf0 - 0xfffff800`36b88490 ]
         +0x008 Blink            : 0xffff9f8c`a5d9f370 _LIST_ENTRY [ 0xfffff800`36b88490 - 0xffff9f8c`a4e30af0 ]

Token Swapping은 이러한 _EPROCESS 구조체에서 해당 프로세스의 권한을 지정하고 있는 _EPROCESS.Token 필드를 SYSTEM 프로세스의 필드가 가리키는 값으로 바꿔치기 하는 것을 지칭합니다.

예를 들어 RW Primitives를 사용하여 _EPROCESS 리스트들을 하나씩 확인하면서, _EPROCESS.ImageFileName을 확인하여 해당 _EPROCESS 구조체가 System 프로세스의 것인지를 확인한 후에 해당 구조체의 _EPROCESS.Token의 포인터 값을 얻어 냅니다.

kd> dt -r _EPROCESS ffff9f8c`a1c54330-2f0
nt!_EPROCESS
   +0x000 Pcb              : _KPROCESS
      +0x000 Header           : _DISPATCHER_HEADER
         +0x000 Lock             : 0n11927555
         +0x000 LockNV           : 0n11927555
         +0x000 Type             : 0x3 ''
   ...
   +0x450 ImageFileName    : [15]  "System"
   ...

다음은 System 프로세스의 Token 값을 덤프한 내용입니다.

kd> dt -r _EPROCESS ffff9f8c`a1c54330-2f0
nt!_EPROCESS
   +0x000 Pcb              : _KPROCESS
      +0x000 Header           : _DISPATCHER_HEADER
         +0x000 Lock             : 0n11927555
         +0x000 LockNV           : 0n11927555
         +0x000 Type             : 0x3 ''

   ...
   +0x358 Token            : _EX_FAST_REF
      +0x000 Object           : 0xffffb900`8ac1804f Void
      +0x000 RefCnt           : 0y1111
      +0x000 Value            : 0xffffb900`8ac1804f
   ...

이후 자신이 권한 상승을 시키려는 프로세스를 찾아 내어 해당 프로세스의 _EPROCESS.Token 필드의 값을 바꿔주면 해당 프로세스에 대한 권한 상승이 일어 나게 됩니다.

이러한 작업은 꼭 RW Primitives 뿐만 아니라, 커널 내에서 쉘코드를 실행시킬 수 있을 경우, 다음과 같이 nt!PsLookupProcessByProcessId 함수를 사용하여 _EPROCESS의 위치를 알아 낸 후에 해당 Token을 수정하는 방법으로 이뤄질 수도 있습니다.

TokenSwappingShellcode

Token Priviledges Modification

두번째 권한 상승 방법은 Token Swapping과 유사하지만, _EPROCESS.Token 포인터를 바꿀 수 없거나, _EPROCESS.Token 포인터의 위치를 찾아 낼 수 없는 경우에 사용됩니다. 예를 들어, 커널에 대한 완벽한 Read 권한을 얻지 못했을 경우, Token 구조체 자체에 대한 주소 만을 누출 시킬 수 있을 경우에만 사용됩니다.

예를 들어 SMBGhost의 danigargu 익스플로잇을 보면, 해당 익스플로잇은 커널에 대해서 손쉽게 Write 권한을 얻어냈지만, 커널 메모리를 자유롭게 읽을 수는 없는 상황이었습니다. 이 경우에는 이 exploit 코드는 프로세스 토큰을 열어서 해당 토큰에 대해서 NtQuerySystemInformation 쿼리에 SystemExtendedHandleInformation 파라메터를 넘겨 주는 방식으로 해당 핸들에 대한 Object 정보를 빼내어 옵니다. 해당 오퍼레이션은 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX에서 보는 봐와 같이 오프셋 +0x0000 에 “PVOID Object” 포인터를 가지고 있습니다. 이 포인터가 커널내에서 해당 토큰 오브젝트를 가리키는 주소를 담고 있습니다.

ULONG64 get_handle_addr(HANDLE h) {
	ULONG len = 20;
	NTSTATUS status = (NTSTATUS)0xc0000004;
	PSYSTEM_HANDLE_INFORMATION_EX pHandleInfo = NULL;
	do {
		len *= 2;
		pHandleInfo = (PSYSTEM_HANDLE_INFORMATION_EX)GlobalAlloc(GMEM_ZEROINIT, len);
		status = NtQuerySystemInformation(SystemExtendedHandleInformation, pHandleInfo, len, &len);
	} while (status == (NTSTATUS)0xc0000004);

	if (status != (NTSTATUS)0x0) {
		printf("NtQuerySystemInformation() failed with error: %#x\n", status);
		return 1;
	}

	DWORD mypid = GetProcessId(GetCurrentProcess());
	ULONG64 ptrs[1000] = { 0 };
	for (int i = 0; i < pHandleInfo->NumberOfHandles; i++) {
		PVOID object = pHandleInfo->Handles[i].Object;
		ULONG_PTR handle = pHandleInfo->Handles[i].HandleValue;
		DWORD pid = (DWORD)pHandleInfo->Handles[i].UniqueProcessId;
		if (pid != mypid)
			continue;
		if (handle == (ULONG_PTR)h)
			return (ULONG64)object;
	}
	return -1;
}

다음은 OpenProcess, OpenProcessToken 그리고 NtQuerySystemInformation 등을 이용하여 _Token 구조체의 커널 주소를 얻어내고, Write Primitive를 사용해 해당 구조체의 Privileges 값을 바꾸어 권한을 상승하는 개념을 도식화한 것입니다.

  1. OpenProcess, OpenProcessToken, NtQuerySystemInformation API 들을 사용하여 SYSTEM_HANDLE_INFORMATION_EX 구조체를 얻어 냅니다.
  2. SYSTEM_HANDLE_INFORMATION_EX 구조체의 Handles 구조체의 어레이를 하나씩 확인하여 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.UniqueProcessId 와 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.HandleValue 값을 각각 해당 프로세스 ID와 OpenProcessToken의 핸들 값과 비교하여 해당 프로세스에 대한 엔트리를 확보합니다.
  3. SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 구조체의 Object 필드를 통해서 해당 프로세스의 _Token 오브젝트 주소를 알아 냅니다.
  4. 기존에 확보한 Write Primitives를 사용하여 _Token.Privileges 필드에 적당한 값을 넣어 줍니다.

다음은 해당 프로세스에 대한 토큰의 Privileges 값의 변화에 따라서 실제 해당 프로세스의 Properties를 Process Explorer로 확인한 결과입니다.

TokenSwappingShellcode

결론

윈도우즈 시스템의 Mitigation의 발전에 따라서 점점 커널에서 코드를 실행하거나 권한 상승을 이뤄내는 것이 어려워지고 있습니다. 하지만, 아직도 윈도우 커널에 존재하는 오브젝트에 대한 메모리 누출이라든지, Write Primitives를 사용한 특정 구조체의 변경에 대해서 윈도우즈 커널이나 VBS가 완벽한 보안책을 제공해 줄 수는 없습니다. 모든 커널 오브젝트와 데이타에 대해서 보호를 하기에는 오버헤드가 너무 크기 때문에 일어나는 근본적인 현상으로 보입니다. 특히 Token 값과 같이 커널 내에서 Read-only로 세팅하기 힘든 이러한 오브젝트들에 대한 보호가 힘든 실정입니다. 이러한 추세에 맞추어 일부 EDR 이나 엔드 포인트 디텍션 시스템의 경우 이러한 불법적인 커널 토큰 변조가 일어 났을 경우 경고를 해 주는 경우도 있습니다. 여러 EDR 선택 시에 유념할 내용 중의 하나이기도 합니다. 왜냐하면, 최근의 많은 윈도우즈 커널 공격들은 거의 대부분의 경우 Token Swapping이나 Token Privileges Modification 기법을 사용하여 권한 상승을 이뤄내고 있고, 이러한 행위를 탐지하는 방법으로 시스템 침해에 대해서 빠른 시간에 디텍션이 가능해 지기 때문입니다.