domenica 29 settembre 2019

22 - Filter Driver

In modo simile a quanto visto nella lezione precedente, in generale è quasi sempre possibile per un device intercettare le richieste fatte ad un altro device. Ad esclusione del file system (che richiede l'uso del Filter Manager ed i Minifilter) e dei device aperti in modo esclusivo (per ovvi motivi), per un driver è possibile "attaccare" il device di cui si occupa in cima ad uno stack relativo al device di cui intende intercettare le richieste (targert device) ed i cui elementi (dello stack) sono device object. Le richieste verranno trasmesse dall'I/O Manager a partire dal device più in alto nello stack (top device). Il driver del top device a quel punto può decidere se completare la richiesta o passarla al device che sta sotto di lui (lower device). Quello che non può fare, però, è trascurare la richiesta perché il client potrebbe ricevere un errore di operazione non valida ingiustificato rispetto ad una operazione che dovrebbe invece essere offerta dal target device. L'immagine sotto è una rappresentazione semplificata di questo meccanismo di filtraggio ma è sufficiente a spiegare quanto affrontato in questa lezione (per una discussione più approfondita si veda [4]).




Per filter driver si intende un driver il cui device si trova su un device stack. A volte quando si ha a che fare con i filter driver ci si avvicina, anche se in maniera indiretta, a device reali e questo porta inevitabilmente a dover esaminare un numero maggiore di dettagli di basso livello. Per questo motivo, almeno per questa lezione, si è scelto di evitare di commentare il codice perché renderebbe lo stesso praticamente illeggibile e si è preferito inserire i commenti dopo il codice di ogni funzione. Il codice dell'esempio è tratto da [1] e propone un keylogger in grado di monitorare la pressione ed il rilascio dei tasti della tastiera. Si è evitato di mappare il codice dei tasti al relativo sistema di caratteri perché solitamente è una informazione che si passa così com'è al client che provvederà poi lui stesso a mappare (ad esempio tramite la funzione MapVirtualKey).

Si parte dall'entrypoint.

#include <ntddk.h>
#include <Ntddkbd.h>
 
 
#define IOCTL_TEST CTL_CODE(FILE_DEVICE_KEYBOARD, 0x800, METHOD_BUFFEREDFILE_ANY_ACCESS)
 
 
typedef struct _DEVICE_EXTENSION
{
 PDEVICE_OBJECT pAttachDevObj;
 ULONG ulIrpInQueue;
 
}DEVICE_EXTENSION, *PDEVICE_EXTENSION;
 
 
 
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObjectPUNICODE_STRING pRegPath)
{
 UNREFERENCED_PARAMETER(pRegPath); 
 
 DbgPrint("Enter DriverEntry\n");
 NTSTATUS status = STATUS_SUCCESS;
 ULONG i = 0;
 
 for (i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
 {
  pDriverObject->MajorFunction[i] = DriverDefaultHandle;
 }
 
 pDriverObject->DriverUnload = DriverUnload;
 
 pDriverObject->MajorFunction[IRP_MJ_READ] = DriverRead; 
 pDriverObject->MajorFunction[IRP_MJ_POWER] = DriverPower;
 
 status = CreateDevice(pDriverObject);
 if (!NT_SUCCESS(status))
 {
  DbgPrint("CreateDevice Error[0x%X]\n", status);
  return status;
 }
 
 status = AttachKdbClass(pDriverObject->DeviceObject);
 if (!NT_SUCCESS(status))
 {
  DbgPrint("AttachKdbClass Error[0x%X]\n", status);
  return status;
 }
 
 DbgPrint("Leave DriverEntry\n");
 return status;
}

Come detto, il filter driver non può trascurare o ignorare alcuna richiesta quindi ogni elemento dell'array MajorFunction deve contenere un puntatore ad una dispatch routine che completa o passa la richiesta al lower device. In questo caso DriverDefaultHandle passa semplicemente la richiesta (tale dispatch routine verrà esaminata più avanti). DriverPower si occupa delle richieste che riguardano l'alimentazione elettrica (fattore che entra in gioco quando si ha a che fare con device hardware) ma la sua implementazione non è importante per questa lezione e non verrà presa in considerazione. DriverRead è la dispatch routine che si occupa delle richieste in lettura e verrà esaminata in seguito.
Anche se il filter driver è interessato ad intercettare le richieste del target device un riferimento al lower device è necessario per passare le richieste a cui non si è interessati e per staccare il device dal device stack quando si ritiene opportuno terminare l'intercettazione. Le opzioni sono due: rendere tale riferimento una variabile globale oppure salvarlo nello spazio di memoria allocato per il device del filter driver. Il più delle volte è consigliabile usare quest'ultima opzione in quanto un driver può registrare più di un device in grado di attaccarsi ad altrettanti device stack e questo incrementerebbe a dismisura il numero di variabili globali richieste. Il luogo deputato a conservare i dati collegati ad un device è lo spazio di memoria aggiuntivo che è possibile allocare in fase di creazione del device e a cui si può fare riferimento tramite il campo DeviceExtension della struttura DEVICE_OBJECT. La struttura utente DEVICE_EXTENSION definita nel codice sopra conterrà i dati relativi a questo device che comprendono: il puntatore al lower device ed un contatore delle richieste non ancora completate. Si passa ora al codice di CreateDevice.

NTSTATUS CreateDevice(PDRIVER_OBJECT pDriverObject)
{
 DbgPrint("Enter CreateDevice\n");
 NTSTATUS status = STATUS_SUCCESS;
 PDEVICE_OBJECT pDevObj = NULL;
 
 status = IoCreateDevice(
  pDriverObjectsizeof(DEVICE_EXTENSION), 
  NULL,
  FILE_DEVICE_KEYBOARD, 
  0, 
  FALSE, 
  &pDevObj);
 if (!NT_SUCCESS(status))
 {
  DbgPrint("IoCreateDevice Error[0x%X]\n", status);
  return status;
 }
 
 DbgPrint("Enter CreateDevice\n");
 return status;
}

L'unica differenza con il codice delle lezioni precedenti è il passaggio di sizeof(DEVICE_EXTENSION) come secondo parametro che permette di allocare spazio aggiuntivo che conterrà i dati collegati a questo device. Da notare anche la mancanza della registrazione di un nome per il device ed il relativo symbolic link in quanto i client faranno riferimento esclusivamente al target device per inviare le richieste. Viene inoltre specificato il valore FILE_DEVICE_KEYBOARD come tipo del device. AttachKdbClass, in DriverEntry, è una funzione utente che si occupa di attaccare il device appena creato al device stack della tastiera.

NTSTATUS AttachKdbClass(PDEVICE_OBJECT pDevObj)
{
 DbgPrint("Enter HookKdbClass\n");
 NTSTATUS status = STATUS_SUCCESS;
 UNICODE_STRING ustrObjectName;
 PFILE_OBJECT pFileObj = NULL;
 PDEVICE_OBJECT pKeyboardClassDeviceObject = NULL, pAttachDevObj = NULL;
 
 RtlInitUnicodeString(&ustrObjectName, L"\\Device\\KeyboardClass0");
 
 status = IoGetDeviceObjectPointer(
  &ustrObjectName,
  GENERIC_READ | GENERIC_WRITE,
  &pFileObj,
  &pKeyboardClassDeviceObject);
 if (!NT_SUCCESS(status))
 {
  DbgPrint("IoGetDeviceObjectPointer Error[0x%X]\n", status);
  return status;
 }
 
 ObDereferenceObject(pFileObj);

 // Se device è tastiera imposta semplicemente Buffered I/O
 pDevObj->Flags |= pKeyboardClassDeviceObject->Flags & (DO_BUFFERED_IO | DO_DIRECT_IO);
 
  pAttachDevObj = IoAttachDeviceToDeviceStack(pDevObj, pKeyboardClassDeviceObject);
 if (NULL == pAttachDevObj)
 {
  DbgPrint("IoAttachDeviceToDeviceStack Error\n");
  return STATUS_UNSUCCESSFUL;
 }
 
 pDevObj->Flags &= ~DO_DEVICE_INITIALIZING;
 pDevObj->Flags |= DO_POWER_PAGABLE;
 
 
 ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->pAttachDevObj = pAttachDevObj;
 ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->ulIrpInQueue = 0;
 
 DbgPrint("Leave HookKdbClass\n");
 return status;
}

IoGetDeviceObjectPointer recupera l'indirizzo del device object il cui nome è passato come primo parametro e lo mette nella variabile passata come ultimo parametro. Come si può notare il nome del device della tastiere è solitamente KeyboardClass0. Oltre a questo IoGetDeviceObjectPointer recupera anche un riferimento al file object relativo al device object che recupera. Questo file object, volendo, può essere usato in seguito per eliminare il device object collegato qualora questo restasse senza riferimenti ed il numero di riferimenti del file object stesso si azzerasse. Ma tutto questo è qualcosa di cui non ci si deve preoccupare poiché, in questo caso, ci sarà sempre qualche riferimento al device della tastiera e deferenziare il file object (operazione che va comunque effettuata) non comporterà l'eliminazione del device della tastiera.
A questo punto si devono impostare i flag del device del filter driver in modo che questi siano compatibili con quelli del target device. Questo perché se il target device usa il Buffered I/O (come è il caso del device della tastiera) l'I/O Manager invierà le richieste con tale supporto attivato a prescindere dal fatto che sullo stack siano presenti filter driver o meno. Se il filter driver in cima allo stack non ha impostato i suoi flag per tale supporto il disastro è assicurato.
IoAttachDeviceToDeviceStack attacca il device object passato come primo argomento al device stack del device passato come secondo argomento. Il valore restituito è l'indirizzo del lower device (ex top device). Si veda anche IoAttachDeviceToDeviceStackSafe nella documentazione ufficiale cercando di cogliere le differenze tra queste due funzioni. Ad ogni modo, quello che è importante notare è che l'aggiunta al device stack avviene solo dopo aver settato i flag del device, altrimenti le possibili richieste inviate tra la chiamata a IoAttachDeviceToDeviceStack e l'impostazione dei flag potrebbero creare problemi di incompatibilità nel supporto del trasferimento dei dati.
Quando si ha a che fare con device hardware, anche indirettamente come in questo caso, entrano in gioco nuovi componenti e richieste di cui occuparsi come il Plug and Play Manager (PnP Manager, incaricato, tra le varie cose, di aggiungere alcuni dei filtri al device stack in fase di avvio) e le richieste che riguardano la gestione dell'alimentazione elettrica. Ora, senza entrare nel dettaglio, rimuovere DO_DEVICE_INITIALIZING dai flag del device serve ad indicare al PnP Manager che il device è pronto. Inoltre tale valore è necessario per permettere agli altri filter driver di attaccare i propri device in cima al device stack. DO_POWER_PAGABLE indica che gli IRP riguardanti l'alimentazione elettrica devono arrivare ad IRP < DISPATCH_LEVEL (necessario per i driver WDM dove le dispatch routine vengono solitamente eseguite ad IRQL = PASSIVE_LEVEL; si veda lezione sui concetti preliminari).
Infine AttachKdbClass salva il puntatore al lower device nello spazio aggiuntivo (o di estensione) del device.

NTSTATUS DriverRead(PDEVICE_OBJECT pDevObjPIRP pIrp)
{
 DbgPrint("\nEnter DriverRead\n");
 NTSTATUS status = STATUS_SUCCESS;
 
 IoCopyCurrentIrpStackLocationToNext(pIrp); 
 IoSetCompletionRoutine(pIrp, ReadCompleteRoutine, pDevObjTRUETRUETRUE);
 
 // Incrementa numero di letture
 ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->ulIrpInQueue++;
 
 status = IoCallDriver(((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->pAttachDevObj, pIrp);
 
 DbgPrint("Leave DriverRead\n");
 return status;
}

DriverRead è la dispatch routine che si occupa di intercettare e gestire le richieste in lettura fatte al device della tastiera. In una lezione precedente (si veda [3]) si è parlato di IRP e del fatto che ad ogni istanza di tale struttura fanno seguito un numero variabile di istanze di tipo IO_STACK_LOCATION che contengono informazioni sulla richiesta. Si è detto anche che solo la prima istanza è inizializzata dall'I/O Manager facendo intendere che ne fossero anche altre. Infatti, quando l'I/O Manager costruisce l'IRP che descrive la richiesta fatta ad un device inserisce tante istanze IO_STACK_LOCATION dopo l'IRP quanti sono i device object nel device stack del target device. Ogni stack location è collegata al device corrispondente nel device stack. In altre parole, il primo stack location è collegato al top device. Il secondo stack location è collegato al device sotto il top device, ecc. La funzione DriverRead è implementata in modo da non completare le richieste in lettura e passare le stesse al lower device. Nell'esecuzione di un qualsiasi trasferimento di questo tipo è richiesto che sia il filter driver che sta passando la richiesta ad inizializzare lo stack location successivo e ad avanzare il puntatore allo stack location corrente. A questo scopo la funzione IoCopyCurrentIrpStackLocationToNext permette di copiare i campi dello stack location corrente in quelli del successivo. Un filter driver può impostare una funzione di completamento tramite la funzione IoSetCompletionRoutine che salva l'indirizzo di tale funzione nel campo CompletionRoutine dello stack location successivo. In pratica è un modo per essere notificati sul completamento della richiesta da parte di un filter driver sottostante.
La chiamata a IoCallDriver passa la richiesta al device object passato come primo parametro (che in questo caso deve essere il lower device e da qui la necessità di salvarne l'indirizzo) ed incrementa il puntatore allo stack location corrente in modo che il filter driver successivo veda lo stack location corretto collegatao al suo device.
Alla fine dei conti quello che fa DriverRead è semplicemente incrementare la variabile ulIrpInQueue (nello spazio di estensione del device) per aggiornare il numero di richieste in lettura arrivate. Come si vedrà più avanti tale variabile verrà usata come contatore delle richieste non ancora completate.

NTSTATUS DriverDefaultHandle(PDEVICE_OBJECT pDevObjPIRP pIrp)
{
 IoSkipCurrentIrpStackLocation(pIrp);
 return IoCallDriver(((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->pAttachDevObj, pIrp);
}

DriverDefaultHandle, come già detto in precedenza, passa semplicemente le richieste al lower device e, naturalmente, non ha nessuna necessità di registrare una funzione di completamento per queste ultime dato che non è interessato a sapere quando e come verranno completate. In questo caso è consigliabile usare la funzione IoSkipCurrentIrpStackLocation al posto di IoCopyCurrentIrpStackLocationToNext in quanto è molto più performante: se non è necessario chiamare IoSetCompletionRoutine per salvare il puntatore alla propria funzione di completamento lo stack location successivo sarà praticamente uguale a quello corrente ed infatti quello che fa IoSkipCurrentIrpStackLocation è semplicemente decrementare il puntatore allo stack location corrente che verrà poi incrementato da IoCallDriver facendo credere al filter driver successivo che è questo il suo stack location. In pratica l'effetto è lo stesso di IoCopyCurrentIrpStackLocationToNext ma al costo di un semplice incremento di puntatore invece che di una pesantissima copia membro a membro.

NTSTATUS ReadCompleteRoutine(PDEVICE_OBJECT pDevObjPIRP pIrpPVOID pContext)
{
 UNREFERENCED_PARAMETER(pContext);
 
 NTSTATUS status = pIrp->IoStatus.Status;
 PKEYBOARD_INPUT_DATA pKeyboardInputData = NULL;
 ULONG ulKeyCount = 0, i = 0;
 
 if (NT_SUCCESS(status))
 {
  pKeyboardInputData = (PKEYBOARD_INPUT_DATA)pIrp->AssociatedIrp.SystemBuffer;
 
  // Information contiene numero di byte trasferiti nell'operazione di lettura.
  ulKeyCount = (ULONG)pIrp->IoStatus.Information / sizeof(KEYBOARD_INPUT_DATA);
 
  // per ogni tasto premuto o rilasciato al momento corrente
  for (i = 0; i < ulKeyCount; i++)
  {
   // Key Press
   if (pKeyboardInputData[i].Flags == KEY_MAKE)
   {
    // Visualizza codice del tasto premuto.
    DbgPrint("[Down][0x%X]\n", pKeyboardInputData[i].MakeCode);
   }
   // Key Release
   else if (pKeyboardInputData[i].Flags == KEY_BREAK)
   {
    // Visualizza codice del tasto rilasciato.
    DbgPrint("[Up][0x%X]\n", pKeyboardInputData[i].MakeCode);
   }
 
   // Togliendo commento a istruzione seguente modifica codice del tasto facendo
   // sembrare che si preme o si rilascia sempre un solo tasto
   // pKeyboardInputData[i].MakeCode = 0x1e;
  }
 }
 
 if (pIrp->PendingReturned)
 {
  IoMarkIrpPending(pIrp);
 }
 
 // Decrementa numero di richieste in attesa di essere completate.
 ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->ulIrpInQueue--;
 
 status = pIrp->IoStatus.Status;
 return status;
}

La funzione di completamento, se impostata dal filter driver corrente per una specifica richiesta, verrà invocata quando un filter driver con device sottostante avrà segnato tale richiesta come completata (di solito invocando IoCompleteRequest). La funzione di completamento è eseguita ad un IRQL$\le 2$ con tutto quello che questo comporta (si veda lezione sui concetti preliminari). Detto questo, il device della tastiera usa il metodo Buffered I/O per il trasferimento dei dati e per questo gli stessi vanno recuperati in maniera congrua e compatibile (si veda lezione sul Buffered I/O).
Una delle azioni comuni nelle funzioni di completamento è quella di controllare se la richiesta è stata marcata come pendente da qualche driver con device sottostante nel driver stack. In questo caso (e quando non c'è necessità di un ulteriore processamento della richiesta da parte della funzione di completamento: cioè nel caso non si debba ritornare STATUS_MORE_PROCESSING_REQUIRED) si deve invocare IoMarkIrpPending per inoltrare questa stessa informazione anche ai driver con device ai livelli più alti. Dopo aver decrementato il numero di richieste in attesa di essere completate la funzione di completamento ritorna lo stesso status conservato nella richiesta. A dire il vero non si è obbligati a fare questo: è possibile ritornare qualsiasi valore (l'effetto è lo stesso) stando però attenti ai casi quando è opportuno ritornare STATUS_MORE_PROCESSING_REQUIRED. Ma dato che nel nostro caso la funzione di completamento non necessita di ulteriore processamento della richiesta è possibile usare lo status della stessa in quanto le dispatch routine solitamente non impostano STATUS_MORE_PROCESSING_REQUIRED per marcare la richiesta come pendente ma usano STATUS_PENDING che è una valore di ritorno valido dal punto di vista della funzione di completamento .

VOID DriverUnload(PDRIVER_OBJECT pDriverObject)
{
 DbgPrint("Enter DriverUnload\n");
 PDEVICE_OBJECT pDevObj = pDriverObject->DeviceObject;
 LARGE_INTEGER liDelay = { 0 };
 
 if (pDevObj == NULL)
 {
  return;
 }
 if (pDevObj->DeviceExtension == NULL)
 {
  return;
 }
 
 IoDetachDevice(((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->pAttachDevObj);
 
 liDelay.QuadPart = -1000000;
 while (0 < ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->ulIrpInQueue)
 {
  KdPrint(("Pending IRP:%d\n", ((PDEVICE_EXTENSION)pDevObj->DeviceExtension)->ulIrpInQueue));
  KeDelayExecutionThread(KernelModeFALSE, &liDelay);
 }
 
 if (pDriverObject->DeviceObject)
 {
  IoDeleteDevice(pDriverObject->DeviceObject);
 }
 
 DbgPrint("Leave DriverUnload\n");
}

La funzione di unload, dopo aver controllato che lo spazio di estensione del device ha un indirizzo e non è nullo (meglio avere un driver che non si può scaricare che un crash), stacca il device dallo stack e aspetta finché ci sono richieste non ancora completate. Dopodiché non resta che eliminare il device object.

Arrivati a questo punto, soddisfatti ma un po' affaticati, qualcuno potrebbe rimanere un po' deluso nell'osservare che questo esempio di codice ha un bug (shock!). Come si può notare dalle immagini seguenti la funzione di completamento non viene invocata al primo IRP (key down) nonostante la richiesta venga correttamente gestita dalla relativa dispatch routine (DriverRead). Questo comporta che il contatore delle richieste non ancora completate non arrivi mai a zero ed è necessario premere un tasto della tastiera dopo l'esecuzione della funzione di unload per forzarlo ad annullarsi e consentire al driver di essere scaricato dalla memoria.






Il problema è che il filter driver che abbiamo scritto va bene per device target di tipo software e meno per device reali, come tastiera, mouse, ecc. In questi casi non si dovrebbe creare il device del filter driver e attaccarlo a sistema già avviato: tale operazione dovrebbe essere fatta all'avvio. Inoltre, avendo a che fare con un device harware bisogna gestire le richieste inviate dal PnP Manager in modo appropriato. Niente paura! Il codice sorgente allegato contiene tutto quanto per poter caricare il driver ed eseguire il codice correttamente. Tutto quello che bisogna fare è scorrere il codice in DriverEntry e, dove indicato, ed eliminare il commento ad alcune istruzioni e commentarne altrettante. In particolare le istruzioni

pDriverObject->DriverExtension->AddDevice = FilterAddDevice;
pDriverObject->MajorFunction[IRP_MJ_PNP] = KbFilter_PnP;
pDriverObject->DriverUnload = KbFilter_Unload;

in DriverEntry impostano:

- una funzione di callback di tipo DRIVER_ADD_DEVICE che verrà invocata dal PnP Manager in fase di avvio quando viene caricato il driver della tastiera e costruito il device stack.

DRIVER_ADD_DEVICE DriverAddDevice;
 
NTSTATUS DriverAddDevice(
 DRIVER_OBJECT *DriverObject,
 DEVICE_OBJECT *PhysicalDeviceObject
);

PhysycalDeviceObject è il puntatore al target device quindi in questo caso non è necessario invocare IoGetDeviceObjectPointer per recuperarlo.

- le corrette dispatch routine per l'unload e per gestire le richieste inviate dal PnP Manager.

Dopodiché è necessario creare le chiavi ed i valori di registro: a tale proposito si può usare KdManager (premendo esclusivamente il pulsante Register) per le chiavi ed i valori del filter driver e poi, manualmente, si deve aggiungere la stringa con il nome del filter driver al valore UpperFilters nella chiave di registro

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Class\{4d36e96b-e325-11ce-bfc1-08002be10318}







Fatto questo basta riavviare e godersi lo spettacolo. Per eliminare il driver è necessario cancellare il nome del filter driver da UpperFilters ed eliminare la chiave del servizio creato in precedenza (manualmente o usando KdManager, premendo il pulsante Unregister) prima di ravviare. Giustamente qualcuno potrebbe chiedersi perché presentare l'esempio in questo modo. Essenzialmente i motivi sono tre:

- Tutte le informazioni presentate nella lezione restano valide a prescindere dal particolare esempio di implementazione.

- Si può avere necessità di attaccare il proprio filter driver a device hardware e software e questa lezione li affronta entrambi.

- La probabilità che qualcuno possa essere tentato nel provare il proprio filter driver con mouse o tastiera è alta e per questo motivo è molto meglio affrontare la cosa e fornire una soluzione, evitando inutili frustrazioni. Anche perché in rete non si trova molto a riguardo.




Codice sorgente:
KeyboardFilterDriver.zip



Riferimenti:
[1] https://github.com/dearfuture/WindowsHack/blob/master/内核层/4/过滤驱动/KeyboardLog_Test/KeyboardLog_Test/Driver.c
[2] https://github.com/9176324/WinDDK/blob/master/3790.1830/src/input/kbfiltr/kbfiltr.c
[3] Comunicazione Client / Driver: IRP
[4] Programming The Microsoft Windows Driver Model - Oney

Nessun commento:

Posta un commento