윈도우즈 커널 공격과 방어 - RW Primitive

윈도우즈 커널 보안은 이제 많은 경우 윈도우즈에서 SYSTEM 권한을 확보하기 위한 마지막 타겟으로 많이 연구 되고 있습니다. 이번 시리즈를 통해서 최신 윈도우즈 커널의 보안 장치들에 대한 기술적인 설명들을 최근의 몇몇 익스플로잇들의 예제를 사용해서 설명할 예정입니다.

공격, 방어 랜드스케이프

kASLR (Kernel Address Space Layout Randomization)과 Read Primitive

윈도우즈에서 커널에의 ASLR 도입은 상당히 일찍 일어 났습니다. kASLR의 도입으로 인해서 커널에서의 Memory Leak 버그나 Read Primitive 등을 사용하여 메모리 주소를 노출해야만 성공적인 익스플로잇이 가능하게 되었습니다. 결국 안정적인 exploit의 작성을 위해서는 Read Primitive의 확보가 중요해 지게 되었습니다.

ROP (Return Oriented Programming)

2017년 마이크로소프트에서 퍼블리슁한 Hardening Windows 10 with zero-day exploit mitigations 블로그의 예에서 보듯이 Memory Leak이나 Read Primitive를 통해서 kASLR을 우회한다고 하여도 명령 실행을 위해서는 커널에 실행 모듈을 인젝션할 수가 없으므로, ROP의 도움을 받을 수 밖에 없습니다. ROP는 Intel CET가 상용화되기 전까지 해결 되지 않는 수십년간 인텔 플랫폼에서 공격방법으로 남아 있습니다.

RW Primitive 의 확보

ROP와 같은 공격 기법을 사용하기 위해서는 결국 커널의 적당한 구조체를 변경하는 방식으로 컨트롤 플로우를 변경해야 합니다. 이러한 작업을 하기 위해서는 Write Primitive등을 사용하여 커널에 자유롭게 데이타를 써 넣는 작업이 필요합니다. Write-What-Where라는 컨셉이라고도 불리는 이러한 기법은 타겟이 되는 Where라는 메모리 주소에 What이라는 데이타를 자유롭게 쓸수 있는 상태를 일컫습니다.

예를 들어 win32k.sys 공격에는 주로 그 편리성으로 인해서 tagWND 구조체가 자주 사용 되었습니다. 이 공격 기법은 꽤 오랫동안 사용되어 왔고, Exploiting the win32k!A chain is only as strong as its weakest Win32k과 같은 문서 등을 통해서 활발히 논의 되어 왔습니다.

tagWND 구조체의 strName 필드는 다음과 비슷한 형태의 UNICODE_STRING 으로서 Length 필드와 MaximumLength 필드를 커럽션 시키면, InternalGetWindowText, SetWindowText, NtUserDefSetText 등의 유저랜드에 노출된 API등을 통해서 커널 메모리를 자유롭게 읽고, 쓰는 것이 가능했습니다.

typedef struct _UNICODE_STRING {
  USHORT Length;
  USHORT MaximumLength;
  PWSTR  Buffer;
} UNICODE_STRING, *PUNICODE_STRING;

Read Primitive나 Write Primitive는 이러한 길이 필드를 가지고 있는 구조체들을 주요 타겟으로해서 만들어집니다. 어떠한 Read, Write Primitive가 한번 발굴되면 벤더에서 그러한 타겟에 대한 mitigation을 적용하는데에 많은 시간이 걸리는 경우가 많으므로 오랜 시간 동안 사용되는 속성을 가지고 있습니다.

이러한 류의 Read, Write Primitive들은 일반적인 mitigation 방법론을 광범위하게 적용해서 없애기에는 까다로운 속성을 가지고 있습니다. 그래서 다음과 같이 자주 사용되는 Write Primitive들에 대한 전술적인 mitigation을 시행합니다. 다음은 2018년 tagWND.strName 필드에 대해서 어떻게 mitigation을 적용했고, 실제 공격이 있어 났을 경우 verification 루틴이 작동하여 공격을 막아내는 루틴의 콜스택입니다. 전술적인 mitigation들은 많은 경우 얼마 되지 않아 다른 공격 기법의 발견으로 쉽게 이어지기도 합니다.

케이스 스터디: SMBGhost (CVE-2020-0796)

2020년 3월 SMBGhost라는 SMB v3 취약점이 출현했습니다.

Name Resources Descriptions Read Primitive Write Primitive
synacktiv I’M SMBGHOST, DABA DEE DABA DA POC uses WindowsProtocolTestSuite to reproduce crash NA NA
eerykitty CVE-2020-0796-PoC Python, uses modified smbprotocol, Crash POC NA NA
danigargu CVE-2020-0796 LPE to SYSTEM, Token Priv Modification, C-code NA (Only uses Token Handle Address Leak) SRVNET_BUFFER_HDR.pNetRawBuffer
ZecOps ZecOps - CVE-2020-0796 Local Privilege Escalation POC Python, LPE, Token Priv Modification NA (Only uses Token Handle Address Leak) SRVNET_BUFFER_HDR.pNetRawBuffer
Almorabea SMBGhost-LPE-Metasploit-Module Using danigargu implmenetation for Metasploit NA (Only uses Token Handle Address Leak) SRVNET_BUFFER_HDR.pNetRawBuffer
ricercasecurity “I’ll ask your body”: SMBGhost pre-auth RCE abusing Direct Memory Access structs Implementation provided only to paid customers, RCE Shell MDL Corruption SRVNET_BUFFER_HDR.pNetRawBuffer
chompie1337 chompie1337/SMBGhost_RCE_PoC Implementation of ricercasecurity article MDL Corruption SRVNET_BUFFER_HDR.pNetRawBuffer

초기 synacktiv, eerykitty의 exploit에서는 크래쉬만을 내고 BSOD를 일으키는 정도에 머물렀지만, danigargu, ZecOps, Almorabea로 넘어 가면서 메모리 릭으로 프로세스의 토큰 주소를 노출 시킨후 Write Primitive를 활용하여 토큰의 권한 값을 덮어 쓰는 방법으로 SYSTEM 권한 상승 익스플로잇을 만듭니다. 이후 일본 보안 회사인 ricercasecurity에서 MDL Corruption을 사용하면서 PTE의 물리적인 주소가 어느 정도 예측 가능하다는 점에 착안해, 안정적으로 커널 메모리 릭을 수행하면서 이미 알려진 여러 기법을 활용해 리모트 쉘을 띄우는 방법을 제시합니다. 다만, 이때에는 exploit 코드는 자사 고객에게만 제공 되었고, 이후 chompie1337라는 리서처가 ricercasecurity에 의해서 제시된 방법론을 실제 코드로 구현해 냅니다.

전체 풀 체인 익스플로잇의 구조와 이해에는 많은 배경 지식이 필요하지만, 이번 아티클에서는 어떻게 Write Primitve를 확보하는지에 대해서 설명하도록 하겠습니다.

SRVNET_BUFFER_HDR.pNetRawBuffer

SMBGhost의 Write Primitive확보는 사실 이 취약점이 영향을 미치는 메모리 레이아웃의 특수성에 있습니다. 취약점의 내용은 다음에서 보듯이 SMB2의 Compression Transform 헤더의 파싱에서 발생합니다. 압축된 데이타의 원래 길이를 지정한 OriginalCompressedSegmentSize 필드와 압축되지 않은 페이로드의 길이를 지정한 Offset/Length 필드를 합하여 메모리 버퍼를 할당하는데, 이 과정에서 sanity check가 이뤄지지 않아 Integer Overflow가 일어 나게 됩니다.

다음은 danigargu의 CVE-2020-0796 익스플로잇의 일부분입니다.

	const uint8_t buf[] = {
		/* NetBIOS Wrapper */
		0x00,
		0x00, 0x00, 0x33,

		/* SMB Header */
		0xFC, 0x53, 0x4D, 0x42, /* protocol id */
		0xFF, 0xFF, 0xFF, 0xFF, /* original decompressed size, trigger arithmetic overflow */
		0x02, 0x00,             /* compression algorithm, LZ77 */
		0x00, 0x00,             /* flags */
		0x10, 0x00, 0x00, 0x00, /* offset */
	};

OriginalCompressedSegmentSize는 다음과 같이 0xFFFFFFFF로 지정되어 있습니다.

0xFF, 0xFF, 0xFF, 0xFF, /* original decompressed size, trigger arithmetic overflow */

Offset/Length 필드는 0x10으로 지정되어 있습니다.

		0x10, 0x00, 0x00, 0x00, /* offset */

두 정수의 합을 통해서 Integer Overflow가 발생하게 되고 적정한 버퍼보다 작은 양의 버퍼가 할당 됩니다.

실제로 커널 디버깅을 통해서 확인해 보면 다음과 같이 rax에 00000000ffffffff가 할당 됩니다.

.text:FFFFF80528067E9B                 movups  xmmword ptr [rsp+58h+Size], xmm0
.text:FFFFF80528067EC8                 mov     rax, qword ptr [rsp+58h+Size]

   fffff805`28067ec8 488b442430      mov     rax,qword ptr [rsp+30h]
   1: kd> db rsp L40
   fffffc0a`a26a5e70  00 00 00 00 00 00 00 00-05 00 00 00 00 00 00 00  ................
   fffffc0a`a26a5e80  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
   fffffc0a`a26a5e90  00 00 00 00 01 00 00 8a-63 7e 06 28 05 f8 ff ff  ........c~.(....
   fffffc0a`a26a5ea0  fc 53 4d 42 ff ff ff ff-02 00 00 00 10 00 00 00  .SMB............

   ->
      1: kd> r @rax
      rax=ffffffff424d53fc
  
.text:FFFFF80528067ECF                 shr     rax, 20h

   1: kd> t
   fffff805`28067ecf 48c1e820        shr     rax,20h
   1: kd> r @rax
   rax=00000000ffffffff

또한 이후 ecx에 0000000000000010의 Offset/Length가 패킷에서 추출 됩니다.

fffff805`28067ed7 03c8            add     ecx,eax
1: kd> r @rcx
rcx=0000000000000010

이후 추가적인 sanity check 없이 rcx가 000000000000000f로 오버플로우 되어 원래의 데이타보다 작은 값을 가지고 있음을 확인할 수 있습니다.

.text:FFFFF80528067ED7                 add     ecx, eax
   1: kd> r @rcx
   rcx=000000000000000f

rcx는 곧 SrvNetAllocateBuffer 콜을 통한 메모리 할당 함수로 넘겨집니다.

fffff805`28067ed9 4c8b15489a0200  mov     r10,qword ptr [fffff805`28091928]
fffff805`28067ee0 e84be8f6ff      call    fffff805`27fd6730 <-- SrvNetAllocateBuffer

다음은 이러한 과정을 도식화한 그림입니다.

  1. OriginalCompressedSegmentSize + Offset/Length 필드의 합을 통해서 Integer Overflow가 발생하게 됩니다.

  2. SrvNetAllocateBuffer와 ExAllocatePoolWithTag 함수 등을 부르면서 예상보다 작은 메모리 버퍼가 할당 됩니다.

  3. 이 버퍼에 압축된 데이타의 압축이 풀린 데이타가 카피 되면서 메모리 커럽션이 발생합니다.

  4. 이 메모리 커럽션은 해당 구조체의 pNetRawBuffer 필드를 덮어 쓰게 됩니다.

  5. 이 pNetRawBuffer 필드는 다시 memcpy를 수행하기 위한 타겟 메모리 주소로 사용되는데, 이 때에 카피 되는 데이타가 원래 패킷의 What에 해당하는 데이타로서 Offset/Length 필드만큼의 데이타를 가집니다. 결국 이러한 특수한 메모리 레이아웃이 안정적인 Write Primitive 형성에 도움을 줍니다.

다음은 이러한 버퍼 오버플로우를 통한 인근 구조체 커럽션에 대한 자세한 도식입니다.

패킷 상에서는 What 필드가 먼저 나타나고 그 이후 Compressed Data가 옵니다. Compressed Data가 풀리면서 SRVNET_BUFFER_HDR.pNetRawBuffer 오버플로우가 발생하게 되면서 Write Primitive가 자연스럽게 형성되고, 이후 memcpy를 통해서 원하는 메모리 주소에 원하는 데이타를 자유롭게 쓸수 있게 됩니다.

결론

윈도우즈 커널 공격과 방어라는 주제로 먼저 RW Primitive 확보에 대해서 알아 보았습니다. RW Primitve들은 공격 서피스에 따라서 다른 대상의 메모리 구조체를 커럽션 시키는 것이 보통입니다. Win32k에서는 주로 tagWND 등의 전통적으로 많이 사용되던 구조체들이 있었고, 새로운 구조체들 또한 지속적으로 발견되고 있습니다. SMB를 통한 공격 기법에서는 SMBGhost와 같이 특수한 메모리 레이아웃으로 인해서 100% 안정적인 Write Primitve가 생성되기도 합니다. 이러한 현상을 막기 위해서는 디자인 단계에서부터 RW Primitive로 악용될 소지가 있는 구조체들의 위치를 랜덤화하는 전술적인 프로그래밍이나 sanity check을 강화하여 예상치 못한 행위들 (범위를 벗어난 메모리 쓰기)을 미연에 방지하려는 노력들이 필요합니다.

다음 화에서는 SMBGhost에 대한 추가적인 기법 정리와 함께 기존 Windows Kernel Exploit들과의 기법 비교, mitigation과의 연관성에 대해서 더 설명하겠습니다.

트레이닝 정보

다른그림은 Threat 인텔리젼스와 지식 제공 회사로서 다음과 같은 Windows Mitigation과 커널과 관련된 트레이닝을 제공합니다. 많은 관심 바랍니다.