
CVE-2020-0796(SMBGhost)에 대한 기술 분석 및 개념 증명(PoC)으로, Windows 10/Server에서 로컬 권한 상승으로 이어지는 SMBv3 압축의 정수 오버플로 취약점입니다.
Windows 10/Server 1903 버전부터 SMBv3에 추가된 compression 기능에는 Microsoft가 2020년 3월 12일에 확인한 integer overflow 취약점이 존재합니다. 이 취약점으로 공격자는 Local Privilege Escalation(LPE) 및 Remote Code Execution(RCE)을 수행할 수 있습니다. 여기서는 LPE 취약점에 대해서만 다룹니다.
영향을 받는 버전:
srv2.sys 파일을 분석하면 Decompress와 관련된 함수들이 다음과 같이 호출되는 것을 확인할 수 있습니다:``` js
Srv2ReceiveHandler
|
|
v
Srv2DecompressMessageAsync
|
|
v
Srv2DecompressData -------> SrvNetAllocateBuffer
|
|
v
SmbCompressionDecompress
|
|
v
memcpy
먼저 `Srv2ReceiveHandler` 함수가 호출되어 SMB 데이터 패킷을 수신하고 `ProtocolId` 프로토콜에 해당하는 함수를 호출합니다. `PrococolId`가 0x424D53FC이면 `Srv2DecompressMessageAsync` 함수를 호출하며, 이 함수는 데이터 패킷의 압축을 해제하기 위해 `Srv2DecompressData` 함수를 호출합니다. `Srv2DecompressData` 함수는 압축 해제 후 데이터를 저장할 `Alloc`을 할당하기 위해 `SrvNetAllocateBuffer` 함수를 호출하고, 이어서 데이터 패킷의 압축을 해제하기 위해 `SmbCompressionDecompress` 함수를 호출한 다음, 마지막으로 `memcpy` 함수를 호출합니다. 따라서 전체 Decompress 과정은 다음과 같은 주요 단계로 구성됩니다:
- 1. Allocate
- 2. Decompress
- 3. Copy
Microsoft가 제공한 문서에 따르면, `COMPRESSION_TRANSFORM_HEADER` 구조는 클라이언트와 서버 간에 압축된 데이터를 송수신하는 데 사용됩니다. 그 구조는 다음과 같습니다:``` c
typedef struct _COMPRESSION_TRANSFORM_HEADER
{
ULONG ProtocolId;
ULONG OriginalCompressedSegmentSize;
USHORT CompressionAlgorithm;
USHORT Flags;
ULONG Offset;
} ;
여기서는 위의 두 가지 주요 필드에만 초점을 맞춥니다:
OriginalCompressedSegmentSize는 압축되지 않은 데이터 세그먼트의 크기(바이트)입니다.Offset은 압축된 데이터의 시작 지점과 _COMPRESSION_TRANSFORM_HEADER 구조의 끝 지점 사이의 오프셋(바이트)입니다.따라서 압축된 데이터 패킷은 다음과 같은 형태를 갖습니다:
``` c
typedef struct _ALLOCATION_HEADER
{
// ...
PVOID UserBuffer;
// ...
} ALLOCATION_HEADER, *PALLOCATION_HEADER;
NTSTATUS Srv2DecompressData(PCOMPRESSION_TRANSFORM_HEADER Header, SIZE_T TotalSize) { PALLOCATION_HEADER Alloc = SrvNetAllocateBuffer( (ULONG)(Header->OriginalCompressedSegmentSize + Header->Offset), NULL); If (!Alloc) { return STATUS_INSUFFICIENT_RESOURCES; }
ULONG FinalCompressedSize = 0;
NTSTATUS Status = SmbCompressionDecompress(
Header->CompressionAlgorithm,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER) + Header->Offset,
(ULONG)(TotalSize - sizeof(COMPRESSION_TRANSFORM_HEADER) - Header->Offset),
(PUCHAR)Alloc->UserBuffer + Header->Offset,
Header->OriginalCompressedSegmentSize,
&FinalCompressedSize);
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
if (Header->Offset > 0) {
memcpy(
Alloc->UserBuffer,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
Header->Offset);
}
Srv2ReplaceReceiveBuffer(some_session_handle, Alloc);
return STATUS_SUCCESS;
}
`Srv2DecompressData` 함수를 분석해 보면, 이 함수는 압축된 데이터 패킷 `COMPRESSION_TRANSFORM_HEADER`(Header)를 입력받아 `Header->OriginalCompressedSegmentSize` + `Header->Offset`의 합을 매개변수로 하여 `SrvNetAllocateBuffer` 함수로 메모리 영역(Alloc)을 할당한 다음, 압축된 데이터를 해제하고 압축되지 않은 데이터를 `Alloc->Buffer`로 복사한다.

Integer overflow는 `Srv2DecompressData`가 `SrvNetAllocateBuffer`를 호출할 때 발생한다. `SrvNetAllocateBuffer`는 실제로 64비트 값 2개를 받지만, `Srv2DecompressData`가 `SrvNetAllocateBuffer`를 호출할 때는 32비트 값(ULONG) 2개만 전달한다. 그런데 `OriginalCompressedSegmentSize`와 `Offset` 모두 ULONG이므로, 둘을 더하면 32비트보다 큰 수가 나올 수 있다. 그래서 integer overflow가 발생한다 (간단히 말하면 0xffffffff(`OriginalCompressedSegmentSize`)와 0x10(`Offset`)을 더하면 0xf0000000f가 되지만, `SrvNetAllocateBuffer`는 0x0000000f만 전달받는다).

Integer overflow는 Alloc 메모리 영역을 잘못 할당하게 만들 수 있고(할당해야 할 크기가 실제 크기보다 작아짐), 이로 인해 buffer overflow가 발생할 수 있다:

Buffer overflow가 실제로 발생하는지, 그리고 어떻게 발생하는지 알아보기 위해 `SrvNetAllocateBuffer`와 `SmbCompressionDecompress` 함수를 분석해 보겠다.``` c
PALLOCATION_HEADER SrvNetAllocateBuffer(SIZE_T AllocSize, PALLOCATION_HEADER SourceBuffer)
{
v2 = *MK_FP(__GS__, 420i64);
v3 = 0;
v4 = a2;
v5 = 0;
if ( SrvDisableNetBufferLookAsideList || allocSize > 0x100100 )
{
if ( allocSize > 0x1000100 )
return 0i64;
v11 = SrvNetAllocateBufferFromPool(allocSize, allocSize);
}
else
{
if ( allocSize > 0x1100 )
{
_RCX = allocSize - 256;
__asm
{
bsr rdx, rcx
bsf rax, rcx
}
if ( (_DWORD)_RDX == (_DWORD)_RAX )
v3 = _RDX - 12;
else
v3 = _RDX - 11;
}
v6 = SrvNetBufferLookasides[(unsigned __int64)v3];
v7 = *(_DWORD *)v6 - 1;
if ( (unsigned int)(unsigned __int16)v2 + 1 < *(_DWORD *)v6 )
v7 = (unsigned __int16)v2 + 1;
v8 = (unsigned int)v7;
v9 = *(_QWORD *)(v6 + 32);
v10 = *(_QWORD *)(v9 + 8 * v8);
if ( !*(_BYTE *)(v10 + 0x70) )
PplpLazyInitializeLookasideList(v6, *(_QWORD *)(v9 + 8 * v8));
++*(_DWORD *)(v10 + 20);
v11 = (unsigned __int64)ExpInterlockedPopEntrySList((PSLIST_HEADER)v10);
if ( !v11 )
{
++*(_DWORD *)(v10 + 24);
v12 = *(_DWORD *)(v10 + 44);
v13 = *(_DWORD *)(v10 + 40);
v14 = *(_DWORD *)(v10 + 36);
LODWORD(v15) = sub_1C00110B0(*(int (**)(void))(v10 + 48));
v11 = v15;
}
v5 = 2;
}
if ( v11 )
{
*(_WORD *)(v11 + 0x10) |= v5;
*(_WORD *)(v11 + 0x12) = v3;
*(_WORD *)(v11 + 0x14) = v2;
if ( v4 )
{
v24 = *(_DWORD *)(v4 + 0x24);
if ( v24 >= *(_DWORD *)(v11 + 0x20) )
v24 = *(_DWORD *)(v11 + 0x20);
v25 = *(void **)(v11 + 0x18);
*(_DWORD *)(v11 + 0x24) = v24;
memcpy(v25, *(const void **)(v4 + 0x18), v24);
v26 = *(_WORD *)(v4 + 0x16);
if ( v26 )
{
*(_WORD *)(v11 + 0x16) = v26;
memcpy((void *)(v11 + 0x64), (const void *)(v4 + 0x64), 0x10i64 * *(_WORD *)(v4 + 0x16));
}
}
else
{
*(_DWORD *)(v11 + 36) = 0;
}
}
return v11;
}
위 코드는 IDA Pro의 Pseudocode에서 가져온 것으로, 꽤 이해하기 어려워 보입니다. 그러나 Zecops가 다시 작성한 코드를 보면 간단히 이해할 수 있습니다:``` c PALLOCATION_HEADER SrvNetAllocateBuffer(SIZE_T AllocSize, PALLOCATION_HEADER SourceBuffer) { // ...
if (SrvDisableNetBufferLookAsideList || AllocSize > 0x100100) {
if (AllocSize > 0x1000100) {
return NULL;
}
Result = SrvNetAllocateBufferFromPool(AllocSize, AllocSize);
} else {
int LookasideListIndex = 0;
if (AllocSize > 0x1100) {
LookasideListIndex = /* some calculation based on AllocSize */;
}
SOME_STRUCT list = SrvNetBufferLookasides[LookasideListIndex];
Result = /* fetch result from list */;
}
// Initialize some Result fields...
return Result;
}
`SrvNetAllocateBuffer` 함수는 할당할 크기를 입력받은 다음, 크기가 0x100100보다 큰지 확인하고, 크면 NULL을 반환합니다. 이 함수는 `SrvDisableNetBufferLookAsideList` 변수도 추가로 확인하지만, 이 변수에 대한 문서는 찾지 못했고 기본적으로 0으로 설정되어 있어서 그리 중요하지 않은 것 같습니다.