SMBGhost(CVE-2020-0796) Exploit

[0x00] Overview

해외의 수 많은 분석 내용과 LPE 분석 내용을 보며 약간의 분석 내용과 익스플로잇 코드 설명을 담았습니다. 해당 챕터에서 내용을 이해한다면 어떻게 작성자가 취약점을 이용하는지 알 수 있습니다.

[0x01] Analysis

지겨울 수 있지만 또 분석입니다. 기존 분석 결과를 토대로 확인하였을 때, 정수 오버플로를 발생시키기 위해 가능한 포인트는 OriginalCompressedSegmentSizeOffset/Length 필드입니다.

위의 PoC에서는 오버플로를 발생시키는 Offset/Length 값을 활용하였습니다. 그 결과, 잘못된 영역을 참조하여 BSOD가 발생했습니다. 그렇다면 정상적인 Offset/Length 값과 OriginalCompressedSegmentSize에 비정상적으로 큰 값을 보내면 어떻게 되는지 확인해봐야 합니다.

우선 결론부터 말한다면 아래의 그림과 같습니다.

위의 그림에 대해 자세히 설명하겠습니다.

기존에 PoC에서 BSOD가 발생한 원인은 정수 오버플로가 발생하며 올바르지 않은 메모리 값을 참조하였기 때문입니다. 그런데 분석 내용 중 SrvNetAllocateBuffer 함수를 호출할 때 다음과 같은 내용을 언급하였습니다.

할당하는 버퍼의 크기를 OriginaCompressionSegmentSize와 Offset 값의 합으로 계산하는데 정수 오버플로가 발생하며 작은 사이즈로 할당하게 됩니다.

위의 그림에서 실제 할당 된 버퍼가 비정상적으로 작은 사이즈로 할당 된 버퍼를 의미합니다. 그렇다면 기존과 다르게 Offset/Length 필드를 정상 값으로 전달하고 OriginalCompressedSegmentSize를 오버플로 값으로 전달하면 위와 같은 그림이 성립하게 됩니다.

이를 확인하기 위해서는 SrvNetAllocateBuffer 함수에 대한 추가 분석이 필요합니다.

[-] SrvNetAllocateBuffer

의사코드를 확인하면 아래와 같습니다. 할당하려는 크기가 약 16mb 보다 큰 경우에는 할당을 실패합니다. 그 외의 경우에는 여러가지 동작이 있지만 결국 중요한 부분은 SrvNetBufferLookasides 배열입니다.

__int64 __fastcall SrvNetAllocateBuffer(unsigned __int64 size, __int64 buffer)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  v2 = __readgsdword(0x1A4u);
  v3 = 0;
  v4 = buffer;
  v5 = 0;
  if ( SrvDisableNetBufferLookAsideList || size > 0x100100 )// 0x100100 = larger than 16mb(16,777,472)
  {
    if ( size > 0x1000100 )
      return 0i64;
    v11 = SrvNetAllocateBufferFromPool(size, size);
  }
  else
  {
    if ( size > 0x1100 )
    {
      v13 = size - 0x100;
      _BitScanReverse64(&v14, v13);
      _BitScanForward64(&v15, v13);
      if ( v14 == v15 )
        v3 = v14 - 0xC;
      else
        v3 = v14 - 0xB;
    }
    v6 = SrvNetBufferLookasides[v3];

해당 배열의 참조를 확인하면 SrvNetCreateBufferLookasides 함수에서 배열이 초기화 된다는 것을 알 수 있습니다.

__int64 SrvNetCreateBufferLookasides()
{
  __int64 *v0; // rdi
  ULONG v1; // er8
  int v2; // er9
  unsigned int i; // ebx
  __int64 v4; // rax
  ULONG v6; // [rsp+30h] [rbp-18h]

  v0 = SrvNetBufferLookasides;
  memset(SrvNetBufferLookasides, 0, 0x48ui64);
  i = 0;
  while ( 1 )
  {
    v4 = PplCreateLookasideList(
           SrvNetBufferLookasideAllocate,
           SrvNetBufferLookasideFree,
           v1,
           v2,
           (1 << (i + 0xC)) + 0x100,
           '00SL',
           v6);
    *v0 = v4;
    if ( !v4 )
      break;
    ++i;
    ++v0;
    if ( i >= 9 )
      return 0i64;
  }
  SrvNetDeleteBufferLookasides();
  return 0xC000009Ai64;
}

위의 의사코드를 코드로 만들어 확인하면 SrvNetBufferLookasides 배열에 9개의 값으로 초기화되며 이는 크기를 의미합니다. (0x1100, 0x2100, 0x4100, 0x8100, 0x10100, 0x20100, 0x40100, 0x80100, 0x100100)

또한 아래 코드를 통해 알 수 있듯이 만일 16mb를 초과하지 않고 SrvDisableNetBufferLookAsideList 플래그가 1로 설정 된 경우에는 SrvNetAllocateBufferFromPool 함수를 통해 메모리를 할당합니다. 여기서 해당 플래그의 용도는 이름에서 알 수 있듯이 lookaside 배열을 사용하지 않는다는 것과 일치합니다.

if ( SrvDisableNetBufferLookAsideList || size > 0x100100 )// 0x100100 = larger than 16mb(16,777,472)
  {
    if ( size > 0x1000100 )
      return 0i64;
    v11 = SrvNetAllocateBufferFromPool(size, size);
  }

해당 내용들을 토대로 분석을 해보면 결국 SrvNetAllocateBufferFromPool 함수가 호출됩니다. 내부에서 ExAllocatePoolWithTag를 이용하여 메모리를 할당하며 이 때 _POOL_TYPE 은 0x200(512)로 NonPagedPoolNx 을 의미합니다.

unsigned __int64 __fastcall SrvNetAllocateBufferFromPool(__int64 a1, unsigned __int64 a2)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

...
  v8 = ExAllocatePoolWithTag(0x200, v7, '00SL');// NonPagedPoolNx(No-Execute)
...
}

해당 위치에서 할당된 버퍼는 바로 Srv2DecompressData에서 확인할 수 있습니다. 이 때 해당 버퍼를 이용하여 패킷 데이터를 처리합니다. 그렇다면 실제 SrvNetAllocateBufferFromPool 함수를 분석하여 어떠한 구조로 이루어져있는지 확인하겠습니다.

[-] SrvNetAllocateBufferFromPool

직접 분석한 결과 아래와 같은 내용을 확인할 수 있었습니다. 여기서 중요한 것은 할당 된 버퍼를 온전히 패킷 데이터로 채워지는 것이 아니라는 점입니다.

unsigned __int64 __fastcall SrvNetAllocateBufferFromPool(_POOL_TYPE PoolType, unsigned __int64 Size)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

  size = Size;
  if ( Size > 0xFFFFFFFF )
    return 0i64;
  if ( Size >= 0xFFFFFFFFFFFFFFB0ui64 )
    return 0i64;
  if ( Size + 0x58 < Size + 0x50 )
    return 0i64;
  size_plus_e8 = Size + 0xE8i64;
  if ( size_plus_e8 < Size + 0x58 )
    return 0i64;
  sizeMdl = MmSizeOfMdl(0i64, Size + 0xE8i64);  // return 0x40;
  sizeMdl2 = sizeMdl + 8;
  if ( sizeMdl + 8 < sizeMdl )
    return 0i64;
  MUL_sizeMdl = 2 * sizeMdl2;
  if ( !is_mul_ok(sizeMdl2, 2ui64) )
    return 0i64;
  BufferSize = MUL_sizeMdl + size_plus_e8;
  if ( MUL_sizeMdl + size_plus_e8 < size_plus_e8 )
    return 0i64;
  if ( BufferSize < 0x1000 )                    // lowest pagesize
  {
    BufferSize = 0x1000i64;
  }
  else if ( BufferSize > 0xFFFFFFFF )
  {
    return 0i64;
  }
  AllocBuffer = ExAllocatePoolWithTag(0x200, BufferSize, '00SL');// NonPagedPoolNx(No-Execute)
  if ( !AllocBuffer )
  {
    _InterlockedIncrement(&unk_1C002DEB8);
    return 0i64;
  }
  v9 = BufferSize + _InterlockedExchangeAdd(&unk_1C002DEB4, BufferSize);
  if ( BufferSize > 0 )
  {
    do
      v10 = dword_1C002DEBC;
    while ( v9 > dword_1C002DEBC && v10 != _InterlockedCompareExchange(&dword_1C002DEBC, v9, dword_1C002DEBC) );
  }
  AllocBuffer_plus_50h = (AllocBuffer + 0x50);  // unknown header?
  v12 = &AllocBuffer[size + 0x57] & 0xFFFFFFFFFFFFFFF8ui64;// unknown data except packet raw data
  *(v12 + 0x30) = AllocBuffer;
  *(v12 + 0x50) = (v12 + sizeMdl2 + 0x97) & 0xFFFFFFFFFFFFFFF8ui64;
  pMdl = ((v12 + 0x97) & 0xFFFFFFFFFFFFFFF8ui64);
  *(v12 + 0x18) = AllocBuffer + 0x50;
  *(v12 + 0x38) = pMdl;
  *(v12 + 0x10) = 0;
  *(v12 + 0x16) = 0;
  *(v12 + 0x20) = size;
  *(v12 + 0x24) = 0;
  v14 = (AllocBuffer + 0x50) & 0xFFF;
  *(v12 + 0x28) = BufferSize;
  *(v12 + 0x40) = 0;
  *(v12 + 0x48) = 0i64;
  *(v12 + 0x58) = 0i64;
  *(v12 + 0x60) = 0;
  pMdl->Next = 0i64;
  pMdl->Size = 8 * (((v14 + size + 0xFFF) >> 0xC) + 6);
  pMdl->MdlFlags = 0;
  pMdl->StartVa = (AllocBuffer_plus_50h & 0xFFFFFFFFFFFFF000ui64);
  pMdl->ByteOffset = v14;
  pMdl->ByteCount = size;
  MmBuildMdlForNonPagedPool(*(v12 + 0x38));
  MmMdlPageContentsState(*(v12 + 0x38), 1i64);
  *(*(v12 + 0x38) + 0xAi64) |= 0x1000u;
  *(&pMdl + 1) = *(v12 + 0x50);
  v15 = *(v12 + 0x18) & 0xFFFFFFFFFFFFF000ui64;
  pMdl = (*(v12 + 0x18) & 0xFFFi64);
  result = v12;
  (*(&pMdl + 1))->Next = 0i64;
  (*(&pMdl + 1))->Size = 8 * (((&pMdl[0x55].MdlFlags + size + 5) >> 0xC) + 6);
  (*(&pMdl + 1))->MdlFlags = 0;
  (*(&pMdl + 1))->StartVa = v15;
  (*(&pMdl + 1))->ByteOffset = pMdl;
  (*(&pMdl + 1))->ByteCount = size;
  *(*(v12 + 0x50) + 0xAi64) |= 4u;
  return result;
}

주석이 되어있는 부분의 코드를 보면 할당된 버퍼로부터 size(param2)와 0x50 만큼(size + 57h, 메모리 정렬) 떨어진 주소를 v12 변수에 담습니다. 어떠한 구조체로 보이며 아래로는 해당 위치부터 특정 값을 채워 넣습니다.

  AllocBuffer_plus_50h = (AllocBuffer + 0x50);  // unknown header?
  v12 = &AllocBuffer[size + 0x57] & 0xFFFFFFFFFFFFFFF8ui64;// unknown data except packet raw data

위의 코드를 보았을 때, 버퍼의 최초 0x50 바이트는 헤더와 같은 별도의 용도로 사용되며, 파라미터로 넘어온 사이즈와 0x50 바이트만큼 떨어진 위치에는 MDL을 포함한 별도의 구조체가 초기화 됩니다.

참조한 문서에는 위의 Unknown StructALLOCATE_HEADER 구조라고 표현하였습니다. 이에 대한 정보를 찾을 수 없어 본인은 위와 같이 표현하였으며 구조체도 약간은 다른 형태를 띄고 있습니다.

이 부분이 중요한 이유는 우리는 비정상적인 원본 패킷 사이즈를 전달하여 버퍼 오버플로를 발생시킬 수 있습니다. 그렇다면 바로 위의 Unknown Struct 구조체를 포함하여 할당된 버퍼의 내용을 마음대로 쓸 수 있습니다.

그렇다면 이제 해당 취약점을 이용한 익스플로잇이 가능한 부분을 찾아야합니다. 이는 srv2!Srv2DecompressData 함수에서 찾을 수 있습니다.

[-] Write-what-where

signed __int64 __fastcall Srv2DecompressData(__int64 a1)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]

...
  if ( SmbCompressionDecompress(
         CompType_LZNT1,
         *(*(v1 + 0xF0) + 0x18i64) + Header.Offset + 0x10i64,
         (*(*(v1 + 0xF0) + 0x24i64) - Header.Offset - 0x10),
         Header.Offset + *(tmpBuff + 0x18),
         Header.OriginalSize,
         &v11) < 0                              // SmbCompressionDecompress(
                                                //  CompType_LZNT1,
                                                //  (pPacketInfo->pHeader) + (Header.Offset+0x10),
                                                //  (pPacketInfo->Length) - (Header.Offset-0x10),
                                                //  (Header.Offset + (tmpBuff + 0x18)),
                                                //  Header.OriginalSize,
                                                //  &v11)
                                                // 
    || (v9 = v11, v11 != Header.OriginalSize) )
  {
    SrvNetFreeBuffer(Buffer);
    return 0xC000090Bi64;
  }
  if ( Header.Offset )
  {
    memmove(*(Buffer + 0x18), (*(*(v1 + 0xF0) + 0x18i64) + 0x10i64), Header.Offset);
    v9 = v11;
  }
  *(Buffer + 0x24) = Header.Offset + v9;
  Srv2ReplaceReceiveBuffer(v1, Buffer);
  return 0i64;
}

바로 memmove(memcpy) 함수를 호출하는 위치에서 Write-what-where 조건이 성립합니다. Buffer+0x18에 있는 주소 값에 패킷 헤더로부터 0x10만큼 떨어진 위치의 데이터를 Offset 만큼 복사합니다.

이에 대한 증명은 아래와 같이 가능합니다. 임의의 큰 사이즈로 버퍼를 전달하면 아래와 같이 오버플로로 인해 잘못된 메모리 참조가 일어나며 해당 위치는 srv2!Srv2DecompressData 함수의 memcpy 부분에서 발생합니다. 이 때 레지스터 값을 확인하면 오버플로로 인해 확실히 Dst 버퍼가 변조되었습니다.

 # Child-SP          RetAddr           Call Site
00 fffff208`eb43ae68 fffff801`0d767f6d srv2!memcpy+0x2b
01 fffff208`eb43ae70 fffff801`0d76699e srv2!Srv2DecompressData+0x10d

2: kd> r
rax=0000001ff2ffffbc rbx=ffffc90192e1e150 rcx=4141414141414149
rdx=bebe87c04ed5348f rsi=0000000000000010 rdi=ffffc90190167050
rip=fffff8010d75f5eb rsp=fffff208eb43ae68 rbp=0000000000000002
 r8=0000000000000010  r9=0000000000000001 r10=ffffc9018a602290
r11=4141414141414141 r12=0000000000000000 r13=ffffc901901fbb80
r14=00000000ffffffff r15=0000000000000000
iopl=0         nv up ei pl nz na pe nc
cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00050202
srv2!memcpy+0x2b:
fffff801`0d75f5eb 488941f8        mov     qword ptr [rcx-8],rax ds:002b:41414141`41414141=????????????????

다음은 Src 메모리에 대한 내용입니다.

1: kd> u fffff801`0d767f52 l6
srv2!Srv2DecompressData+0xf2:
fffff801`0d767f52 488b87f0000000  mov     rax,qword ptr [rdi+0F0h]
fffff801`0d767f59 448bc6          mov     r8d,esi
fffff801`0d767f5c 488b4b18        mov     rcx,qword ptr [rbx+18h]
fffff801`0d767f60 488b5018        mov     rdx,qword ptr [rax+18h]
fffff801`0d767f64 4883c210        add     rdx,10h
fffff801`0d767f68 e85376ffff      call    srv2!memcpy (fffff801`0d75f5c0)

1: kd> u @rip l1
srv2!Srv2DecompressData+0x100:
fffff801`0d767f60 488b5018        mov     rdx,qword ptr [rax+18h]

1: kd> db poi(@rax+18)
ffffc901`90f657d0  fc 53 4d 42 ff ff ff ff-02 00 00 00 10 00 00 00  .SMB............
ffffc901`90f657e0  bc ff ff f2 1f 00 00 00-bc ff ff f2 1f 00 00 00  ................
ffffc901`90f657f0  ff ff 3f 40 41 07 00 0f-ff 04 11 e0 70 1e 6c 0d  ..?@A.......p.l.
ffffc901`90f65800  b7 ff ff 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
ffffc901`90f65810  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
ffffc901`90f65820  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
ffffc901`90f65830  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
ffffc901`90f65840  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................

rax+18h의 내용은 SMB 패킷의 헤더입니다. 해당 디버깅 내용은 실제 LPE 의 내용이며 +10h 위치의 값은 특권을 의미하는 Privileges 값입니다. 해당 값이 어디서 구하는지에 대한 답은 의외로 가까운 곳에 있습니다.

System 프로세스의 토큰의 특권 레벨 값을 확인하면 아래와 같습니다.

1: kd> dt_TOKEN 0xffffb70d63c07390 Privileges
nt!_TOKEN
   +0x040 Privileges : _SEP_TOKEN_PRIVILEGES
1: kd> dx -id 0,0,ffffc9018ac84300 -r1 (*((ntkrnlmp!_SEP_TOKEN_PRIVILEGES *)0xffffb70d63c073d0))
(*((ntkrnlmp!_SEP_TOKEN_PRIVILEGES *)0xffffb70d63c073d0))                 [Type: _SEP_TOKEN_PRIVILEGES]
    [+0x000] Present          : 0x1ff2ffffbc [Type: unsigned __int64]
    [+0x008] Enabled          : 0x1e60b1e890 [Type: unsigned __int64]
    [+0x010] EnabledByDefault : 0x1e60b1e890 [Type: unsigned __int64]

그럼 이제 Write-what-where 조건이 성립한다는 사실을 알았습니다.

[0x02] Local Privilege Escalation

해당 PoC 코드는 여기 에서 확인할 수 있습니다. 간단히 내용에 대해 설명하겠습니다.

코드를 보며 해당 내용을 확인하시길 바랍니다. 먼저 해당 기법은 Winlogon.exe와 같이 특권 레벨이 System과 같은 프로세스를 이용하며, 해당 프로세스 메모리에 쉘 코드를 작성하고 스레드를 생성하여 cmd를 실행합니다.

이 때 Winlogon.exe 프로세스 메모리에 내가 원하는 값을 쓰기 위해서도 이에 맞는 권한이 필요합니다. 해당 권한을 취약점을 이용해 습득하여 위의 모든 상황을 가능케 합니다. 간략하게 과정을 그림으로 표현해봤습니다.

[0x03] Reference

  1. Zecops PoC
  2. CSDN_1
  3. CSDN_2
  4. 360 Security
  5. Synacktiv.com
  6. secrss
  7. MS-SMB2

[0x04] Conclusion

이번 포스팅은 굉장히 길었습니다. 하지만 앞으로 다른 내용들도 길 예정입니다. 많은 문서들을 종합하여 확인하고 이에 대해 증명하며 분석을 했습니다. 추가적으로 원격 명령 실행의 경우도 위와 같은 원리로 가능할 것으로 보입니다.

감사합니다.