
Dans la vulnérabilité SMBGhost (CVE-2020-0796), j'ai parlé d'une technique de primitive write-what-where via l'utilisation d'un bug d'overflow d'entier pour modifier le pointeur Alloc.Userbuffer afin qu'il pointe vers une adresse de notre choix et d'y écrire des données arbitraires. Tout comme SMB Ghost, cette vulnérabilité existe également dans la fonction Srv2DecompressData de srv2.sys. Revenons sur la fonction Srv2DecompressData liée à la vulnérabilité SMBGhost (CVE-2020-0796), qui a été simplifiée par 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;
}
La fonction Srv2DecompressData reçoit un message compressé envoyé par le client et alloue une zone mémoire nécessaire, puis décompresse le message dans cette zone. Ensuite, si le champ Offset est non nul, elle copie les données (RawData) précédant les données compressées au début de la zone mémoire allouée.

Le bug SMBGhost réside dans le fait que la fonction ne vérifie pas les dépassements d'entiers (integer overflow), ce qui conduit à une allocation de taille incorrecte et provoque un débordement de tampon (buffer overflow). Trois mois après que Microsoft a corrigé SMBGhost, la faille CVE-2020-1206 (SMBleed, selon la dénomination du [Zecops Blog](https://blog.zecops.com/)) a été découverte. Cette faille nous permet de fuiter l'adresse d'une autre machine et, combinée à SMBGhost, elle peut permettre d'obtenir une RCE. Pour avoir une vision plus simple de la fonction Srv2DecompressData, nous réutiliserons cette fonction telle qu'elle était avant le correctif de SMBGhost, tout en supposant qu'elle a été corrigée.
# Falsifier OriginalCompressedSegmentSize
Comme pour SMBGhost, cette fois nous allons également falsifier OriginalCompressedSegmentSize avec une valeur légèrement supérieure aux données décompressées que nous envoyons. Par exemple, si nous compressons des données d'une taille de x octets, au lieu de placer x dans le champ OriginalCompressedSegmentSize, nous y placerons x + 0x1000 ; l'image suivante sera plus claire :

Les données du noyau non initialisées seront considérées comme faisant partie du message.
Comme je l'ai dit dans l'analyse [CVE-2020-0796](https://github.com/datntsec/CVE-2020-0796), Srv2DecompressData ignore toujours la phase de vérification après la fonction SmbCompressionDecompress si la décompression réussit :``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
Bien que le champ OriginalCompressedSegmentSize soit défini à x + 0x1000 au lieu de x, après une décompression réussie, la variable FinalCompressedSize ne contient pas la valeur x, mais contiendra la valeur 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;
}
Parce qu'après une décompression réussie, FinalCompressedSize est mis à jour pour contenir la valeur CompressedBufferSize (correspondant à OriginalCompressedSegmentSize passé à la fonction SmbCompressionDecompress). La mise à jour et la vérification ultérieures sont presque superflues, ce qui peut entraîner des erreurs inattendues.
# Exploitation de base
La structure de message utilisée par Zecops pour démontrer la vulnérabilité est le [message SMB2 WRITE](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8). Cette structure contient des champs tels que le nombre d'octets pouvant être écrits, le flag, ..., suivis d'un tampon de longueur arbitraire. C'est plutôt parfait pour exploiter la vulnérabilité, car on peut créer un message et spécifier l'en-tête, avec un tampon contenant des données non initialisées.
Basé sur le POC de [Zecops](https://blog.zecops.com/) sur le dépôt WindowsProtocolTestSuites de Microsoft, pour avoir une vision plus claire de cela, nous allons ajouter cette petite extension à la fonction de 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.
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 trong msv1_0.dll module. 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à ý 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ữ lệ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)
Les pointeurs `UserBufferPtr`, `PoolAllocationPtr`, `pMdl1`, `pMdl2` pointent dans le bloc mémoire alloué dans le pool, avec des offsets qui peuvent être calculés à l'avance, il suffit donc d'en lire un seul. Le fait de disposer d'un pointeur vers le bloc mémoire alloué dans le pool nous sera certainement utile pour l'exploitation. Les pointeurs suivants sont également très importants :
- **ConnectionBufferList** : une liste chaînée de tous les buffers reçus mais pas encore traités pour une connexion. La tête de cette liste est un objet de connexion créé par la fonction SrvNetAllocateConnection dans srvnet.sys. Un buffer est ajouté à la liste par la fonction SrvNetWskReceiveComplete. Dans notre cas, il n'y aura qu'un seul buffer dans la liste, donc les deux pointeurs (Flink et Blink de la structure LIST_ENTRY) pointent tous deux vers la tête de liste à l'intérieur de l'objet de connexion.
- **pSrvNetWskStruct** : initialement, un pointeur vers l'objet de connexion mentionné ci-dessus. Le pointeur est défini par la fonction SrvNetWskReceiveEvent, mais il est écrasé par la fonction SrvNetWskReceiveComplete avec un pointeur vers la structure SRVNET_BUFFER_HDR. Par conséquent, le lire n'est pas plus utile que de lire l'un des quatre pointeurs mentionnés ci-dessus. Au passage, si vous cherchez « pSrvNetWskStruct », vous verrez qu'il joue un rôle dans l'exploitation d'EternalBlue.
- **TracingPtr1/2** : ces pointeurs ne sont utilisés que lorsque la fonctionnalité de tracing est activée.

Comme vous pouvez le voir, le seul autre pointeur utile à lire est un pointeur dans la structure ConnectionBufferList. Les deux pointeurs (Blink et Flink dans la structure LIST_ENTRY) pointent vers l'objet de connexion. Cet objet a été nommé SRVNET_RECV par le chercheur d'EternalBlue, nous utiliserons donc également ce nom.
## Obtenir une adresse de base de module
À présent, nous savons comment obtenir deux pointeurs — l'un vers le bloc mémoire alloué dans le pool et l'autre vers la structure SRVNET_RECV — nous pouvons donc modifier librement les deux buffers à l'aide de la primitive write-what-where. Il existe probablement plusieurs façons d'obtenir une exécution de code à distance (RCE), mais obtenir l'adresse de base d'un module est le choix le plus simple, car il y a beaucoup d'éléments que nous pouvons modifier dans la section data d'un module. Comme nous l'avons vu, aucun pointeur dans le bloc mémoire alloué par SrvNetAllocateBuffer ne pointe vers un module. Cependant, certains pointeurs pointent vers des modules :

Notre technique de lecture ne nous permet de lire que les données dans la zone « User Buffer », alors que ces pointeurs sont assez loin et sont référencés par de nombreux autres pointeurs. Nous avons besoin d'un extrait de code capable de faire ce qui suit pour copier la valeur du pointeur dans la zone « User Buffer » :``` c
ptr1 = *(pSrvNetRecv + offset1)
value = *ptr1
ptr2 = *(pSrvNetRecv + offset2)
*ptr2 = value
Si nous pouvions trouver un tel extrait de code, nous l'activerions pour copier le premier pointeur (par exemple, HandlerFunctions) dans la "User Buffer", le lire, puis copier le second pointeur (par exemple, le pointeur de fonction Srv2ConnectHandler) dans la "User Buffer" et le lire, pour en déduire l'adresse de base du module. L'équipe Zecops a cherché un tel extrait de code pendant longtemps, mais n'en a trouvé aucun qui corresponde. Finalement, ils ont utilisé une autre option liée à la fonction SrvNetFreeBuffer (simplifiée comme ci-dessous) dont la fonctionnalité est presque celle souhaitée :``` 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');
}
}
Lors de la libération du tampon, si les flags du buffer sont définis à 0x02 (ce qui signifie que le buffer fait partie d'une lookaside list) et 0x01 (ce qui signifie que le buffer n'a pas d'en-tête de transport), un certain nombre d'opérations sont effectuées sur deux objets MDL afin d'ajouter l'en-tête de transport avant de redéfinir les flags à 0 et de renvoyer le buffer à la lookaside list. Si l'on examine de plus près les opérations effectuées sur les objets MDL, on remarque que le code réalise un double-dereference-read suivi d'un double-dereference-write sur deux variables que nous contrôlons (deux pointeurs MDL), ce qui est exactement ce que nous recherchons. L'inconvénient est que le contenu que nous voulons lire est également modifié, un effet secondaire que nous espérons éviter.
Avec ce qui précède, voici comment nous parvenons à lire le pointeur AcceptSocket :
1. Préparer le buffer A à partir d'une lookaside list de sorte que la zone « User buffer » soit remplie de zéros. La zone user buffer de ce buffer contiendra le pointeur que nous allons lire.
2. Préparer le buffer B à partir d'une autre lookaside list afin que :
- Le pointeur pMdl1 pointe vers l'adresse du pointeur AcceptSocket moins 0x18 (car l'offset de MappedSystemVa est 0x18 dans la structure MDL).
- Le pointeur pMdl2 pointe vers la zone « User buffer » du buffer A.
- Le champ Flags soit défini à 0x03.
Nous pouvons écraser les champs de la structure SRVNET_BUFFER_HDR en les décompressant depuis un buffer plus grand grâce à la technique décrite dans la section [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) ci-dessus.
3. Lorsque le buffer B est libéré, les opérations suivantes se déroulent :
- Les flags MDL sont lus depuis le second MDL du buffer A. Si le flag MDL_PARTIAL_HAS_BEEN_MAPPED est défini, MmUnmapLockedPages sera appelé et le système risque de planter. C'est pourquoi nous devons remplir le buffer de zéros à l'étape 1.
- Le pointeur AcceptSocket et la mémoire qui l'entoure seront modifiés comme décrit ici :```
+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

Bonne nouvelle : nous avons lu le pointeur. Mauvaise nouvelle : nous avons endommagé certaines données dans la structure SRVNET_RECV. Heureusement pour nous, l'erreur n'affecte pas le système tant que rien ne se produit sur la connexion concernée. Quand quelque chose se produit, par exemple la fermeture de la connexion, le système plante. Ce n'est pas un problème car nous aurons bientôt un RCE et nous pourrons corriger le bug si nous le souhaitons.
Après avoir lu le pointeur AcceptSocket, nous continuons à utiliser la même technique pour lire le pointeur srvnet!SrvNetWskConnDispatch. La raison pour laquelle nous lisons le pointeur AcceptSocket et non le pointeur HandlerFunctions est que le tableau de HandlerFunctions est partagé entre toutes les connexions, alors que le buffer pointé par AcceptSocket n'est pas partagé avec les autres connexions. Par conséquent, si nous endommageons des parties de AcceptSocket, cela n'affectera que la stabilité d'une seule connexion.
Si nous avons une copie du fichier srvnet.sys utilisé sur la machine cible, nous pouvons facilement en déduire l'adresse de base du module srvnet.sys en soustrayant l'offset du pointeur SrvNetWskConnDispatch que nous avons réussi à leak.
Supposons que nous avons l'adresse de base du module srvnet.sys, nous pouvons appeler n'importe quelle fonction du module. Mais qu'en est-il des arguments de la fonction ? La fonction srv2!Srv2ReceiveHandler est appelée par SrvNetCommonReceiveHandler et l'appel a la forme suivante :``` c HandlerFunctions = *(pSrvNetRecv + 0x118); Arg1 = *(ULONG_PTR)(pSrvNetRecv + 0x128); Arg2 = *(ULONG_PTR)(pSrvNetRecv + 0x130); (HandlerFunctions[1])(Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8);
Les deux premiers arguments sont lus à partir de la structure SRVNET_RECV, nous pouvons donc les contrôler, mais nous ne pouvons pas contrôler les arguments restants. La convention d'appel x86-64 spécifie que l'appelant est responsable de l'allocation et de la libération de l'espace de pile pour les arguments ; ainsi, même si l'appel d'une fonction à 8 arguments était prévu, nous pouvons remplacer le pointeur par n'importe quelle autre fonction.

Voici les étapes que nous utiliserons pour déclencher l'appel de fonction :
1. Envoyer un message spécialement conçu pour que le pointeur de structure SRVNET_RECV de la connexion soit copié dans un buffer que nous pouvons lire.
2. Envoyer un autre message valide, qui réutilisera la même structure SRVNET_RECV, sans fermer la connexion. Notez que lorsque la connexion est fermée, la structure SRVNET_RECV n'est pas libérée. La fonction SrvNetPrepareConnectionForReuse est appelée pour réinitialiser la structure afin qu'elle puisse être réutilisée pour la prochaine connexion.
3. Lire le pointeur de structure SRVNET_RECV que nous avons copié à l'étape 1.
4. Remplacer le pointeur HandlerFunctions et les arguments en utilisant une primitive write-what-where.
5. Envoyer un message supplémentaire via la connexion de l'étape 2 afin que la fonction remplaçante de srv2!Srv2ReceiveHandler soit appelée.
Maintenant, tout ce que nous avons à faire est de trouver une fonction pour copier la mémoire d'un endroit à un autre, afin de pouvoir copier de la mémoire arbitraire dans un pool buffer que nous pouvons lire. memcpy est une option et srvnet.sys dispose d'une telle fonction (plus précisément memmove), mais cette fonction nécessite un troisième argument, celui qui détermine le nombre d'octets à copier, que nous ne contrôlons pas. Cependant, nous ne sommes pas limités aux fonctions implémentées dans srvnet.sys ; nous pouvons également appeler des fonctions depuis la table d'importation de srvnet, et la fonction RtlCopyUnicodeString est un choix parfait pour accomplir ce que nous voulons.
La fonction RtlCopyUnicodeString prend deux pointeurs UNICODE_STRING en arguments et copie le contenu de la chaîne source vers la chaîne destination. Contrairement aux chaînes C terminées par NULL, les chaînes du noyau sont définies par la structure UNICODE_STRING qui contient un pointeur vers la chaîne et la longueur de la chaîne en octets. Le buffer de la chaîne peut contenir des données binaires arbitraires. Si vous regardez le code de la fonction RtlCopyUnicodeString, vous pouvez voir que la copie est effectuée à l'aide de la fonction memmove, c'est-à-dire une copie pure de données binaires. Tout ce que nous avons à faire est de préparer deux structures UNICODE_STRING et d'appeler RtlCopyUnicodeString, puis de lire les données copiées :

## Exécution du shellcode
Après avoir obtenu une primitive de lecture arbitraire pratique, nous passons au défi suivant, vers l'objectif d'exécution de code à distance via l'exécution d'un shellcode. Nous utiliserons la technique présentée par Morten Schenk lors de sa présentation [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) (pages 47-51).
L'idée est d'écrire un shellcode sous la structure KUSER_SHARED_DATA dont l'adresse est constante, la seule adresse non randomisée dans la disposition mémoire du noyau des versions récentes de Windows. Ensuite, il faut modifier l'entrée de table de pages correspondante pour rendre la page exécutable. L'adresse de base des entrées de table de pages dans le noyau est aléatoire, mais elle est récupérée depuis la fonction MiGetPteAddress dans ntoskrnl.exe. Voici les étapes que nous utiliserons pour exécuter notre shellcode :
1. Utiliser la primitive de lecture arbitraire pour obtenir l'adresse de base de ntoskrnl.exe à partir de la table d'importation de srvnet.
2. Lire l'adresse de base des entrées de table de pages depuis la fonction MiGetPteAddress, comme décrit dans les slides de Morten.
3. Écrire le shellcode à l'adresse KUSER_SHARED_DATA + 0x800 (0xFFFFF78000000800). Notez que nous pouvons également utiliser l'un des pool buffers pour stocker le shellcode ; utiliser KUSER_SHARED_DATA permet de simplifier les choses.
4. Calculer l'adresse de l'entrée de table de pages correspondante et effacer le bit NX pour permettre l'exécution, comme décrit dans les slides de Morten.
5. Appeler le shellcode en utilisant la technique présentée ci-dessus pour appeler une fonction arbitraire.
Le shellcode que Zecops utilise pour le reverse shell est le [shellcode de sleepya](https://github.com/worawit/MS17-010/tree/master/shellcode), écrit pour l'exploitation d'EternalBlue. Ils ont modifié ce shellcode pour qu'il fonctionne sur les versions récentes de Windows.
# Débogage

Les informations d'un paquet SMB ont une structure quasi identique à celle ci-dessus. Supposons que nous ayons besoin de fuiter l'adresse du User Buffer ; il nous faudra lire le pointeur UserBufferPtr. Pour lire ce pointeur, nous exploiterons la [technique](https://github.com/datntsec/CVE-2020-1206#srvnetallocatebuffer-and-the-allocated-buffer-layout) consistant à définir le champ offset à une valeur située au-delà du pointeur, afin que les données soient copiées dans la zone user buffer d'un autre buffer.
Un exemple de paquet SMB envoyé par le client a le contenu suivant :```c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x0
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x2116
Data = ‘A’ * 0x1101.
Ce paquet, lorsqu'il arrive au serveur, est stocké dans un buffer créé par la fonction SrvNetAllocateBuffer. Comme la taille totale du paquet est comprise entre 0x1100 et 0x2100, cette fonction retourne une allocation avec une zone user buffer de taille 0x2100 (que nous appellerons Alloc A), puis y stocke les informations envoyées par le client comme illustré ci-dessous :

On peut voir que la plage allant de l'adresse 0xffffd38439044050 à 0xffffd38439045160 correspond aux données envoyées par le client, la plage de 0xffffd38439045160 à 0xffffd38439046150 correspond aux données non initialisées côté serveur, et la plage de 0xffffd38439046150 à 0xffffd38439046240 correspond aux données de la structure SRVNET_BUFFER_HDR de l'Alloc A. Ainsi, le pointeur que nous voulons lire se trouve à 0xffffd38439046150 + 0x18 = 0xffffd38439046168.
Pour lire ce pointeur, j'ai utilisé la technique que j'ai mentionnée ci-dessus, en définissant le champ offset au-delà du pointeur à lire. C'est pourquoi, bien que le paquet ci-dessus ait une taille inférieure à 0x2100, l'offset a été défini à 0x2116.
Ensuite, le serveur SMB appelle la fonction SrvNetAllocateBuffer pour allouer une zone mémoire basée sur la somme de OriginalCompressedSegmentSize et de l'Offset (0x2116). Il alloue ainsi une allocation avec une zone user buffer de taille 0x4100 (que nous appellerons Alloc B). Les données allouées se présentent comme suit :

Pour éviter les erreurs inattendues, j'ai auparavant créé à plusieurs reprises des buffers de la même liste lookaside que l'Alloc B et les ai remplis d'octets 0x0.
Ensuite, le serveur SMB procède à la décompression des données compressées et copie les données non compressées envoyées par le client dans la zone user buffer de l'Alloc B :

Comme on peut le voir, il n'y a pas de données compressées car OriginalCompressedSegmentSize = 0 ; le programme copie les données de 0xffffd38439044060 à 0xffffd38439044060 + 0x2116 = 0xffffd38439046176 de l'Alloc A dans la zone user buffer de l'Alloc B. Ainsi, une partie des informations de SRVNET_BUFFER_HDR de l'Alloc A a été copiée dans la zone user buffer de l'Alloc B.
Nous allons maintenant utiliser la technique mentionnée précédemment pour fuiter l'adresse du pool d'allocation (l'adresse du User Buffer).
Supposons que nous voulions savoir si un octet à l'adresse 0xffffd3843636f15e est supérieur à 0x7f ou non. Nous allons créer un paquet SMB avec les informations suivantes :``` c Header:
Pourquoi faut-il créer un tel SMB, nous allons l'analyser point par point. D'abord, la somme de OriginalCompressedSegmentSize et Offset est 0x4100, ainsi un alloc avec un user buffer similaire sera réutilisé, c'est-à-dire l'Alloc B utilisé précédemment. Comme nous voulons deviner si 1 octet à l'adresse 0xffffd3843636f15e est supérieur à 0x7f ou non, cette adresse se situe à 0x210e de l'adresse du user buffer, donc les données non compressées auront 0x210e octets ('B' * 0x210e). Ensuite viendra une zone de données compressées valides (compressées par la fonction compress()), suivie de données compressées invalides ('\xff' * 0x1fe9). Ainsi, lors de la décompression, seule la partie des données compressées valides sera décompressée dans un autre alloc, puis la connexion sera interrompue en raison des données compressées invalides qui suivent, la zone de données non compressées ne sera pas copiée dans cet alloc, de sorte que les données que nous avons copiées précédemment seront conservées telles quelles.

Ci-dessus se trouve un Alloc contenant les informations dont nous avons parlé, créé par le serveur SMB. Ensuite, le serveur SMB appellera la fonction SrvNetAllocateBuffer pour créer un Alloc correspondant. Comme la somme de OriginalCompressedSegmentSize et Offset est 0x4100, l'Alloc B sera réutilisé :

Ensuite, le serveur SMB procède à la décompression des informations envoyées par le client dans la zone user buffer de l'Alloc B correspondant à l'offset.

Les données en rouge sont les données décompressées, le reste sera conservé tel quel. Comme le montre l'image ci-dessus, l'octet que nous devons connaître sera conservé, et juste après se trouvent les données qui viennent d'être décompressées.
Pour savoir si cet octet est supérieur à 0x7f ou non, nous procédons comme suit :
Nous continuons à créer un paquet SMB avec le contenu suivant :``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x2004
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x20fd
Data = ‘B’ * 0x20f1
Bien que la somme d'OriginalCompressedSegmentSize et d'Offset soit supérieure à 0x4100, lors de l'allocation de la zone alloc pour stocker le paquet envoyé par le client (avec une taille totale inférieure à 0x4100), le serveur SMB n'alloue toujours qu'un Alloc dont la zone de user buffer est de 0x4100 octets, comme dans l'image ci-dessous :

Les données surlignées en vert ci-dessus sont celles de l'Alloc B alloué précédemment, réutilisées car faisant partie de la même lookaside list.
Ensuite, le serveur SMB appelle la fonction SrvNetAllocateBuffer pour allouer un Alloc contenant les données après décompression :

Grâce à la décompression, le serveur SMB récupère les données à partir de User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Les données extraites se présentent comme suit :

Avec l'algorithme de décompression décrit ci-dessus, il extrait les 2 premiers octets et les utilise comme length du bloc ; en fonction de cette length, il extrait la partie suivante après la length et la décompresse.
Comme ci-dessus, la length sera 0xB0D3 ; cependant, selon l'algorithme, sa length réelle suit la formule : length = length & 0xFFF + 1 → la length sera 0xD4. Il extrait les D4 octets suivants et décompresse normalement jusqu'à rencontrer un octet FF (car les 0xD4 octets incluent tous les octets 00 et une partie des octets FF), à ce moment les données compressées sont considérées comme invalides, il arrête la décompression et coupe la connexion.

En nous basant sur la coupure de connexion du serveur, nous pouvons deviner que l'octet que nous devons connaître est supérieur à 0x7f.
Qu'en est-il si l'octet à deviner est plus petit ? Nous continuons l'analyse ci-dessus, mais cette fois nous utilisons l'octet de comparaison D7, ainsi D3 est inférieur à D7. Regardons ce qui se passe :
Tout d'abord, envoyons au serveur SMB le paquet suivant :``` c Header:
Du côté du serveur SMB, une allocation de stockage sera créée comme suit :

Ensuite, il appellera la fonction SrvNetAllocateBuffer pour allouer un bloc comme suit :

Bien sûr, cette allocation est réutilisée à partir de l’allocation ayant la même lookaside list (Alloc B). Ensuite, le programme procède à une décompression normale avec les données compressées valides, et se déconnecte avec les données compressées invalides :

À l’étape suivante, on envoie à nouveau des données similaires à la fois précédente, et le serveur SMB alloue une allocation correspondante :

Les données colorées en vert ci-dessus sont celles de l’Alloc B allouée précédemment ; comme elles partagent la même lookaside list, elles sont réutilisées.
Ensuite, le serveur SMB appellera la fonction SrvNetAllocateBuffer pour allouer une allocation contenant les données après décompression :

Lors de la décompression, le serveur SMB récupère les données à partir de l’adresse du buffer utilisateur + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Les données extraites se présentent comme suit :

Comme la fois précédente, la longueur initiale est 0xB0D3, et après calcul elle devient 0xD4. Il extrait ensuite les D4 octets suivants et procède à une décompression normale. Cependant, comme D4 < D7, les données compressées extraites pour la décompression ne contiennent alors que des octets à zéro. Il décompresse normalement jusqu’à la fin de ce bloc. Ensuite, il récupère la longueur du bloc suivant à partir de la longueur du bloc précédent ; les deux octets suivants, D5 et D6, valent 0x0, donc la longueur est maintenant 0x0. Par conséquent, la longueur extraite est 0x0000 → fin du processus de décompression → décompression réussie → le serveur SMB renvoie une réponse → on sait alors que l’octet recherché est inférieur ou égal à 0xD7.
On répète ce processus jusqu’à ce que les 6 octets d’une adresse soient entièrement révélés. On obtient ainsi l’allocation pool address.
Une fois l’allocation pool address obtenue, on recherche l’adresse de base de srvnet en récupérant le pointeur vers la structure SRVNET_RECV, de la même manière que pour révéler l’allocation pool address.
Après avoir obtenu les deux adresses — l’allocation pool et SRVNET_RECV, respectivement `0xffffd38439044000` et `0xffffd3843654ddd8` — on procède à la révélation de l’adresse de base de srvnet.


Pour lire le pointeur AcceptSocket, il faut procéder comme suit :
1. Préparer une Alloc A depuis une lookaside list de sorte que la zone « User buffer » soit remplie de zéros. Ce buffer contiendra ensuite le pointeur que l’on souhaite lire. Ici, l’Alloc A sera issue de l’allocation correspondant à l’allocation pool address que l’on a révélée. Par conséquent, la zone User buffer de l’Alloc A commencera à l’adresse 0xffffd38439044050, en utilisant une lookaside list commune.
2. Préparer une Alloc B depuis une autre lookaside list afin que :
- Le pointeur pMdl1 pointe vers l’adresse du pointeur AcceptSocket moins 0x18 (car l’offset de MappedSystemVa est 0x18 dans la structure MDL).
- Le pointeur pMdl2 pointe vers la zone « User buffer » du Buffer A.
- Le champ Flags soit défini sur 0x03.
Ainsi, les adresses des deux pointeurs Mdl sont respectivement : mdl1_ptr : `0xffffd3843654de68`, mdl2_ptr : `0xffffd38439045250`.
On peut écraser les champs de la structure SRVNET_BUFFER_HDR en les décompressant depuis un buffer plus grand, grâce à la technique décrite dans la section [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression).
Je détaillerai cette étape juste après l’étape 4.
3. Lorsque le Buffer B est libéré, les opérations suivantes se déroulent :
- Les MDL flags sont lus depuis le second MDL dans le buffer A. Si le flag MDL_PARTIAL_HAS_BEEN_MAPPED est défini, MmUnmapLockedPages sera appelé et le système risque de planter. C’est pourquoi il faut remplir le buffer de zéros à l’étape 1.
- La zone « User buffer » de l’Alloc A est modifiée et contient les informations que l’on souhaite lire.
4. Lire le pointeur AcceptSocket depuis la zone « User buffer » du buffer A.
- Utiliser la technique de révélation d’adresse déjà employée ci-dessus pour lire le pointeur AcceptSocket.
Je vais maintenant décrire plus en détail les étapes ci-dessus :
L’étape 1 est assez simple et similaire à ce qui précède, je n’y reviendrai donc pas.
À l’étape 2, on commence par créer un paquet envoyé au serveur SMB avec le contenu suivant :``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = -0x38
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x10138
Data = ‘A’ * 0x10138 + compress(mdl1_ptr + ‘\x00’*0x10 + mdl2_ptr) + ‘\xff’*0x10
Comme on peut le voir, OriginalCompressedSegmentSize contient une valeur négative et la somme OriginalCompressedSegmentSize + Offset = 0x10100. Cependant, la taille du paquet que le client envoie au serveur est supérieure à 0x10100. Ainsi, l'Alloc initial créé par le serveur avant la décompression sera plus grand que l'Alloc contenant les données après décompression. La valeur OriginalCompressedSegmentSize est définie comme négative ici afin que la somme de OriginalCompressedSegmentSize et de l'Offset soit exactement égale à 0x10100, sans affecter la position des données compressées, car celle-ci dépend de l'Offset. Quant à 0x38, c'est l'offset du pointeur Mdl1 dans la structure SRVNET_BUFFER_HDR.
Ainsi, le serveur va créer un Alloc contenant les données du client comme suit :

Ensuite, il appelle la fonction SrvNetAllocateBuffer pour allouer un alloc dont la taille de la zone User buffer est 0x10100, c'est-à-dire l'Alloc B selon les étapes ci-dessus :

On procède à la décompression, qui ne peut bien sûr décompresser qu'une partie des données valides :

D'après les images ci-dessus, on peut voir que la zone des deux pointeurs Mdl dans SRVNET_BUFFER_HDR de l'Alloc B a été modifiée avec la valeur souhaitée.
De la même manière que précédemment, cette fois nous allons définir le flag sur 3 en ajustant l'offset du paquet envoyé comme suit :``` c Header:
Finalement, cela aura la forme :

Lorsque Alloc B est libéré, les morceaux de code suivants seront exécutés :``` 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'];
Avec `allocation_pool_object_ptr` étant l'adresse du pool d'allocation leakée et `OFFSETS['srvnet!imp_IoSizeofWorkItem']` étant l'offset de la fonction IoSizeofWorkItem importée par srvnet.
Ces 2 structures UNICODE_STRING seront stockées à `allocation_pool_object_ptr + 0x1650` via la technique Write-what-where trouvée dans CVE-2020-0796.
D'abord, nous allons stocker la chaîne Unicode de destination dans `allocation_pool_object_ptr + 0x1650`, puis nous créons le paquet SMB comme suit :``` 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)
Ci-dessus, les données contiennent une sentinelle générée par la fonction os.urandom(2), d'une longueur de 2 octets, et ces 2 octets nous aideront à savoir si l'adresse que nous avons leakée est bien celle que nous devons leaker, en la comparant après la réussite du processus de leak.
Si la taille totale du paquet envoyé par le client est supérieure à 0x1100 (cela dépendra des données randomisées avant la compression), alors allocation_pool_object_ptr sera certainement utilisé pour le stocker sur le serveur SMB :

Ensuite, le serveur SMB appelle la fonction SrvNetAllocateBuffer pour allouer une zone mémoire pour la décompression. Mais comme la somme de OriginalCompressedSegmentSize et Offset est 0x21, il n'alloue qu'une zone avec un tampon utilisateur de taille 0x1100 :

Le débordement de tas se produit (comme décrit dans CVE-2020-0796) et après que le serveur SMB a décompressé (avant que la copie des données non compressées ait lieu), on aura :

Ainsi, le pointeur UserBufferPtr pointe maintenant vers le début de allocation_pool_object_ptr + 0x1650 et lorsque le processus de copie a lieu, allocation_pool_object_ptr devient :

De même, nous insérerons une autre sentinelle en dessous en envoyant le paquet suivant :``` c Header:
Ainsi, lorsque le serveur SMB reçoit le paquet, il alloue la zone mémoire correspondante ; la zone allouée est alors allocation_pool_object_ptr et elle contient les données suivantes :

Après le processus de décompression, les données seront :

Nous avons donc créé 2 chaînes Unicode et deux sentinelles pour valider les données que nous avons leakées.
Ensuite, nous allons appeler la fonction `RtlCopyUnicodeString` et lui passer les deux chaînes Unicode ci-dessus.
Pour appeler la fonction `RtlCopyUnicodeString`, nous allons d'abord écraser le pointeur HandlerFunctions avec l'adresse de la fonction `RtlCopyUnicodeString`. Cette fonction est importée par le module srvnet et a un offset (selon mon module) de 0x32288.
Ainsi, avec la technique write-what-where, nous allons écrire l'adresse 0xFFFFF80060E70000 + 0x32288 - 0x8 dans HandlerFunctions.
Tout d'abord, nous allons leak un pointeur SRVNET_RECV (0xffffe00f0b593dd8).

Nous allons conserver la connexion pour continuer à envoyer les paquets ci-dessous.
Ensuite, nous allons utiliser la technique write-what-where pour écrire dans le pointeur RtlCopyUnicodeString - 0x8 (la raison de -0x8 est de faire en sorte que la fonction RtlCopyUnicodeString remplace la fonction Srv2ReceiveHandler dans HandlerFunctions)

Ensuite, nous allons écrire successivement les 2 pointeurs des 2 chaînes Unicode créées ci-dessus dans les 2 arguments de HandlerFunction

À ce stade, sur la même connexion, la fonction Srv2ReceiveHandler a été remplacée par RtlCopyUnicodeString ; par conséquent, lorsque nous envoyons un paquet, la fonction RtlCopyUnicodeString sera appelée et copiera la chaîne Unicode.

La prochaine étape consiste à leak 10 octets d'adresse de 0xffffd38439045670 à 0xffffd3843904567a (y compris les 2 sentinelles aux deux extrémités de l'adresse à leak). Ensuite, nous vérifions si les 2 octets au début et à la fin de l'adresse leakée sont bien des sentinelles ; si c'est le cas, nous avons leaké la bonne adresse (0xfffff8068152c380).
Après avoir leaké l'adresse nt!IoSizeofWorkItem (0xfffff8068152c380), nous soustrayons son offset (0x12C380) pour obtenir l'adresse de base de ntoskrnl (0xfffff80681400000)
Notez que chaque offset de chaque fichier module varie selon les versions de Windows ; assurez-vous donc d'avoir le bon fichier module sur la machine cible.
De même, une fois l'adresse de base de ntoskrnl obtenue, nous obtenons MiGetPteAddress (0xBA968) et l'adresse de base PTE (MiGetPteAddress + 0x13) :

À l'étape suivante, nous allons écrire le shellcode à 0xFFFFF78000000800 en utilisant la technique write-what-where. Ensuite, nous recalculons l'adresse du shellcode dans la pte via la formule ci-dessous et effaçons le bit NX pour que le shellcode puisse être exécuté :``` c
shellcode_addr >>= 9
shellcode_addr &= 0x7FFFFFFFF8
shellcode_addr += pte_base
Enfin, nous écrirons l'adresse du shellcode dans allocation_pool_object_ptr + 0x50 + 0x1600, puis appellerons le shellcode en remplaçant cette adresse par HandlerFunctions et en passant l'adresse nt_base_ptr au shellcode.
Profitez du RCE :))
DatntSec. Viettel Cyber Security.
| → Allocation size ↓Logical Processor | 0x1100 | 0x2100 | 0x4100 | 0x8100 | 0x10100 | 0x20100 | 0x40100 | 0x80100 | 0x100100 |
|---|
| Processor 1 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| Processor 2 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| ... | |||||||||
| Processor n | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |